From 0f1a43bb4c1a4a966c891fc8eaba5d6e857ddcec Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:27:46 -0700 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=94=90=20feat:=20Add=20Google=20OAu?= =?UTF-8?q?th=20Login=20to=20Admin=20Panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a "Continue with Google" button on the admin login page alongside the existing OpenID flow, fully delegating to LibreChat's already-wired /api/admin/oauth/google routes (no upstream changes needed). Provider availability is now sourced from LibreChat's public /api/config startup payload — the same endpoint LibreChat's own client uses — instead of the openid-only /admin/oauth/openid/check endpoint. This makes the admin panel pick up github/discord/saml automatically once they're added to the registry, with no per-provider check endpoints required. Detection layer landed as a small src/constants/oauth.ts registry plus a generalized oauthLoginFn / oauthExchangeFn pair parameterized by provider. The exchange wire body to LibreChat is unchanged; provider tagging is admin-panel-internal and drives session.tokenProvider for telemetry. Refresh-token cookie forwarding stays openid-only because LibreChat's /api/auth/refresh only branches on token_provider=openid (and only when OPENID_REUSE_TOKENS is set). Google sessions correctly fall through the default JWT refresh path. ADMIN_SSO_ONLY semantics extended for the multi-provider world: hides the password form whenever any SSO provider is enabled, auto-redirects only when exactly one provider is configured. --- e2e/auth.setup.ts | 2 +- e2e/login.spec.ts | 34 ++++-- e2e/mock-backend.mjs | 23 +++- src/components/AuthCard.tsx | 179 ++++++++++++++++++---------- src/constants/index.ts | 1 + src/constants/oauth.ts | 28 +++++ src/locales/en/translation.json | 2 + src/routeTree.gen.ts | 21 ++++ src/routes/auth/google/callback.tsx | 74 ++++++++++++ src/routes/auth/openid/callback.tsx | 2 +- src/routes/login.tsx | 25 ++-- src/server/auth.oauth.test.ts | 60 ++++++---- src/server/auth.ts | 115 ++++++++++++------ src/types/auth.ts | 35 +++++- src/types/server.ts | 24 +++- 15 files changed, 478 insertions(+), 147 deletions(-) create mode 100644 src/constants/oauth.ts create mode 100644 src/routes/auth/google/callback.tsx diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts index 60791e4..4f88c59 100644 --- a/e2e/auth.setup.ts +++ b/e2e/auth.setup.ts @@ -25,7 +25,7 @@ setup('authenticate as admin', async ({ page }) => { await page.locator('input:not([type="password"])').first().fill(email); await page.locator('input[type="password"]').first().fill(password); - await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); const errorBanner = page.locator('[role="alert"]'); await expect(errorBanner).not.toBeVisible({ timeout: 5_000 }); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 6283c83..696dcf4 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -21,7 +21,7 @@ test.describe('Login page accessibility', () => { }); test('has zero violations after showing validation errors', async ({ page }) => { - const signInButton = page.getByRole('button', { name: 'Sign In', exact: true }); + const signInButton = page.getByRole('button', { name: 'Sign in', exact: true }); await signInButton.click(); await expect(page.getByText(/required/i).first()).toBeVisible({ timeout: 5000 }); @@ -34,7 +34,7 @@ test.describe('Login page accessibility', () => { }); test('validation errors are announced via live region', async ({ page }) => { - const signInButton = page.getByRole('button', { name: 'Sign In', exact: true }); + const signInButton = page.getByRole('button', { name: 'Sign in', exact: true }); await signInButton.click(); const liveRegion = page.locator('.auth-card [role="status"][aria-live="polite"]'); @@ -111,7 +111,7 @@ test.describe('Login page functionality', () => { test('shows validation error for invalid email format', async ({ page }) => { await page.locator('input:not([type="password"])').first().fill('notanemail'); await page.locator('input[type="password"]').first().fill('somepassword'); - await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await expect(page.getByText(/valid email/i).first()).toBeVisible({ timeout: 5000 }); }); @@ -126,7 +126,7 @@ test.describe('Login page functionality', () => { test('successful login redirects away from /login', async ({ page }) => { await page.locator('input:not([type="password"])').first().fill('admin@test.com'); await page.locator('input[type="password"]').first().fill('password'); - await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await page.waitForURL('**/', { timeout: 15_000 }); expect(page.url()).not.toContain('/login'); @@ -135,7 +135,7 @@ test.describe('Login page functionality', () => { test('shows error banner when server rejects credentials', async ({ page }) => { await page.locator('input:not([type="password"])').first().fill('rejected@test.com'); await page.locator('input[type="password"]').first().fill('wrongpass'); - await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await expect( page.getByText(/invalid|failed|credentials/i).first(), @@ -152,7 +152,7 @@ test.describe('2FA verification flow', () => { async function triggerTwoFAStep(page: import('@playwright/test').Page) { await page.locator('input:not([type="password"])').first().fill('2fa@test.com'); await page.locator('input[type="password"]').first().fill('password'); - await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await expect(page.getByText(/authenticator/i)).toBeVisible({ timeout: 5000 }); } @@ -206,18 +206,18 @@ test.describe('2FA verification flow', () => { await page.getByText(/back to login/i).click(); - await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); await expect(page.getByText(/two-factor/i)).not.toBeVisible(); }); }); test.describe('SSO availability', () => { - test('shows SSO button when OpenID is available', async ({ page }) => { + test('shows OpenID button when OpenID is available', async ({ page }) => { await page.goto('/login'); await page.waitForLoadState('networkidle'); await expect( - page.getByRole('button', { name: /sso/i }), + page.getByRole('button', { name: /openid/i }), ).toBeVisible({ timeout: 10_000 }); }); @@ -225,7 +225,7 @@ test.describe('SSO availability', () => { await page.goto('/login'); await page.waitForLoadState('networkidle'); - await expect(page.getByRole('button', { name: /sso/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /openid/i })).toBeVisible({ timeout: 10_000 }); const results = await new AxeBuilder({ page }) .withTags(WCAG_TAGS) @@ -238,8 +238,20 @@ test.describe('SSO availability', () => { await page.goto('/login'); await page.waitForLoadState('networkidle'); - await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); await expect(page.locator('input:not([type="password"])')).toBeVisible(); await expect(page.locator('input[type="password"]')).toBeVisible(); }); + + test('shows Google button alongside OpenID when both are configured', async ({ page }) => { + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + await expect( + page.getByRole('button', { name: /google/i }), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByRole('button', { name: /openid/i }), + ).toBeVisible({ timeout: 10_000 }); + }); }); diff --git a/e2e/mock-backend.mjs b/e2e/mock-backend.mjs index 667175d..8e7e0c9 100644 --- a/e2e/mock-backend.mjs +++ b/e2e/mock-backend.mjs @@ -76,9 +76,28 @@ const handlers = { }, ]; }, - 'GET /api/admin/oauth/openid/check': () => [ + /** + * Mirrors LibreChat's public /api/config payload — the same endpoint the + * admin panel uses to discover which OAuth providers are enabled. Only the + * auth-relevant flags are enumerated. Both openid and google are enabled by + * default so the e2e suite can exercise the multi-provider UI path. + */ + 'GET /api/config': () => [ 200, - { message: 'OpenID check successful' }, + { + appTitle: 'LibreChat', + openidLoginEnabled: true, + googleLoginEnabled: true, + githubLoginEnabled: false, + discordLoginEnabled: false, + facebookLoginEnabled: false, + appleLoginEnabled: false, + samlLoginEnabled: false, + socialLoginEnabled: true, + openidLabel: 'Continue with OpenID', + openidAutoRedirect: false, + emailLoginEnabled: true, + }, ], }; diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index 756dd65..dc38cb5 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -1,19 +1,42 @@ import { z } from 'zod'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { useQuery } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { useState, useEffect, useRef, useMemo } from 'react'; -import { Alert, Title, Panel, Button, Separator, TextField, Container } from '@clickhouse/click-ui'; +import { + Alert, + Title, + Panel, + Button, + Logo, + Separator, + TextField, + Container, +} from '@clickhouse/click-ui'; import type * as t from '@/types'; -import { adminLoginFn, adminVerify2FAFn, openIdCheckOptions, openidLoginFn } from '@/server'; import { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from './InputOTP'; +import { adminLoginFn, adminVerify2FAFn, oauthLoginFn } from '@/server'; import { PasswordInput } from './PasswordInput'; +import { OAUTH_PROVIDERS } from '@/constants'; import { useLocalize } from '@/hooks'; +function renderProviderGlyph( + provider: t.ResolvedProvider, + def: t.OAuthProviderDef, +): React.ReactNode { + if (provider.imageUrl) { + return ; + } + if (def.logo) { + return ; + } + return null; +} + export function AuthCard({ redirectTo = '/', - autoRedirectSso = false, - ssoAvailable: ssoAvailableProp, + providers = [], + ssoOnly = false, + autoRedirectProvider, }: t.AuthCardProps) { const router = useRouter(); const localize = useLocalize(); @@ -24,20 +47,16 @@ export function AuthCard({ const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [announcement, setAnnouncement] = useState(''); - const [ssoLoading, setSsoLoading] = useState(false); + const [pendingProvider, setPendingProvider] = useState(); const [autoRedirectFailed, setAutoRedirectFailed] = useState(false); const autoRedirectAttempted = useRef(false); const [tempToken, setTempToken] = useState(''); const [totpCode, setTotpCode] = useState(''); - const { data: openIdData } = useQuery({ - ...openIdCheckOptions, - enabled: ssoAvailableProp === undefined, - }); - const ssoAvailable = ssoAvailableProp ?? openIdData?.available ?? false; - - const showAutoRedirect = autoRedirectSso && !autoRedirectFailed; + const showAutoRedirect = !!autoRedirectProvider && !autoRedirectFailed; + /** Hide the password form only when ssoOnly is set AND at least one provider is configured. */ + const hidePasswordForm = ssoOnly && providers.length > 0; useEffect(() => { const messages = [generalError, errors.email, errors.password].filter(Boolean); @@ -51,11 +70,11 @@ export function AuthCard({ }, [generalError, errors.email, errors.password]); useEffect(() => { - if (!autoRedirectSso || autoRedirectAttempted.current) return; + if (!autoRedirectProvider || autoRedirectAttempted.current) return; autoRedirectAttempted.current = true; - setSsoLoading(true); - openidLoginFn() + setPendingProvider(autoRedirectProvider); + oauthLoginFn({ data: { provider: autoRedirectProvider } }) .then((result) => { if (result.error || !result.authUrl) { setAutoRedirectFailed(true); @@ -72,8 +91,8 @@ export function AuthCard({ setAutoRedirectFailed(true); setGeneralError(localize('com_auth_sso_redirect_failed')); }) - .finally(() => setSsoLoading(false)); - }, [autoRedirectSso, localize, redirectTo]); + .finally(() => setPendingProvider(undefined)); + }, [autoRedirectProvider, localize, redirectTo]); const emailSchema = useMemo( () => z.string().email(localize('com_auth_email_invalid')), @@ -191,11 +210,11 @@ export function AuthCard({ if (e.key === 'Enter') handleLogin(); }; - const handleSsoLogin = async () => { - if (ssoLoading) return; - setSsoLoading(true); + const handleProviderLogin = async (provider: t.OAuthProvider) => { + if (pendingProvider) return; + setPendingProvider(provider); try { - const result = await openidLoginFn(); + const result = await oauthLoginFn({ data: { provider } }); if (result.error) { setGeneralError(result.message || localize('com_auth_login_failed')); return; @@ -206,7 +225,7 @@ export function AuthCard({ } catch { setGeneralError(localize('com_auth_unable_connect')); } finally { - setSsoLoading(false); + setPendingProvider(undefined); } }; @@ -231,6 +250,8 @@ export function AuthCard({ ); } + const showPasswordForm = !hidePasswordForm; + return ( ) : ( <> - { - setEmail(value); - if (errors.email) setErrors((prev) => ({ ...prev, email: undefined })); - }} - onKeyDown={handleKeyDown} - error={errors.email} - /> - - { - setPassword(value); - if (errors.password) setErrors((prev) => ({ ...prev, password: undefined })); - }} - onKeyDown={handleKeyDown} - error={errors.password} - /> - - + ) : ( + - ) : ( - + ); +} From 0f27dd6a92b0dc03373f1639435164e17a7e9f6b Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:47:29 -0700 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Honor=20router=20ba?= =?UTF-8?q?sepath=20in=20Google=20callback=20retry=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Google callback's error-state retry was rendered as a bare anchor to /login, which bypasses the router's basepath. Under VITE_BASE_PATH=/admin that anchor navigates to the domain root and lands outside the mounted admin panel, where it 404s. The OpenID callback already uses TanStack Link for the same path; routing the Google callback through Link too keeps both flows aligned and basepath-correct. --- src/routes/auth/google/callback.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/routes/auth/google/callback.tsx b/src/routes/auth/google/callback.tsx index 5dce978..89ecaee 100644 --- a/src/routes/auth/google/callback.tsx +++ b/src/routes/auth/google/callback.tsx @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createFileRoute, redirect } from '@tanstack/react-router'; +import { Link, createFileRoute, redirect } from '@tanstack/react-router'; import { oauthExchangeFn } from '@/server'; import { useLocalize } from '@/hooks'; @@ -62,12 +62,13 @@ function GoogleCallback() { {localize('com_auth_sso_error_title')}

{errorMessage}

- {localize('com_auth_sso_back_to_login')} - + ); From 238067eac4d9e5af4f699f691b2d1694bd386ae5 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:47:33 -0700 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=8E=AD=20fix:=20Point=20Playwright?= =?UTF-8?q?=20mock-backend=20readiness=20probe=20at=20/api/config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webServer readiness URL still targeted the removed /api/admin/oauth/openid/check endpoint, so Playwright spent the configured 10s timeout polling a 404 before declaring the mock backend ready. /api/config is the live provider-discovery endpoint the admin panel now reads on the SSR login boundary and is served from the same mock process, so it doubles as a real liveness signal. --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 1615dab..aea72b8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ }, { command: 'node e2e/mock-backend.mjs', - url: 'http://localhost:3081/api/admin/oauth/openid/check', + url: 'http://localhost:3081/api/config', reuseExistingServer: true, timeout: 10_000, }, From 22eb4e3fdd1a3ac723fcdc1bd4e218b56d45ac7f Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:17:04 -0700 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=94=90=20fix:=20Gate=20social=20SSO?= =?UTF-8?q?=20buttons=20and=20forward=20tenant=20on=20startup=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getStartupConfigFn` previously surfaced any provider whose `*LoginEnabled` flag was true, even when LibreChat had not enabled social login at all. With `ALLOW_SOCIAL_LOGIN=false` (the default in LibreChat's `.env.example`) the upstream `configureSocialLogins` never registers the `googleAdmin` passport strategy, so the admin Google button auto-redirected into an "Unknown authentication strategy" 500 and `ADMIN_SSO_ONLY=true` deployments hid the password fallback behind it. The provider registry now carries an opt-in `social` flag, and `getStartupConfigFn` filters social providers on `socialLoginEnabled` while leaving openid (its own registration path) unaffected. The same call now forwards the `X-Tenant-Id` header from the BFF request to LibreChat's `/api/config`, which is mounted behind `preAuthTenantMiddleware`. Without it, multi-tenant deployments with tenant-specific `registration.socialLogins` fell back to the base config and rendered the wrong provider set for the tenant the user was actually accessing. --- src/constants/oauth.ts | 1 + src/server/auth.oauth.test.ts | 31 ++++++++++++++++++++++++++++++- src/server/auth.ts | 20 +++++++++++++++++++- src/types/auth.ts | 7 +++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/constants/oauth.ts b/src/constants/oauth.ts index 0b837f8..ef7291f 100644 --- a/src/constants/oauth.ts +++ b/src/constants/oauth.ts @@ -24,5 +24,6 @@ export const OAUTH_PROVIDERS: ReadonlyArray = [ defaultLabelKey: 'com_auth_provider_google', logo: 'google', enabledKey: 'googleLoginEnabled', + social: true, }, ]; diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 5deeafb..1947d16 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -145,6 +145,7 @@ describe('getStartupConfigFn', () => { jsonResponse(200, { openidLoginEnabled: true, googleLoginEnabled: true, + socialLoginEnabled: true, openidLabel: 'Corp SSO', openidImageUrl: 'https://corp.example/logo.png', }), @@ -159,7 +160,7 @@ describe('getStartupConfigFn', () => { ], ssoOnly: false, }); - expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/config'); + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/config', { headers: {} }); }); it('marks the session SSO-only when ADMIN_SSO_ONLY=true', async () => { @@ -174,6 +175,34 @@ describe('getStartupConfigFn', () => { }); }); + it('hides social providers when LibreChat has not enabled social login', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + openidLoginEnabled: true, + googleLoginEnabled: true, + socialLoginEnabled: false, + }), + ); + + const result = await getStartupConfigFn(); + + expect(result).toEqual({ + providers: [{ id: 'openid', label: undefined, imageUrl: undefined }], + ssoOnly: false, + }); + }); + + it('forwards X-Tenant-Id to the LibreChat startup config request', async () => { + requestHeaders.set('x-tenant-id', 'tenant-42'); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { openidLoginEnabled: true })); + + await getStartupConfigFn(); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/config', { + headers: { 'X-Tenant-Id': 'tenant-42' }, + }); + }); + it('hides every SSO provider without calling the backend when ADMIN_SSO_ENABLED=false', async () => { process.env.ADMIN_SSO_ENABLED = 'false'; diff --git a/src/server/auth.ts b/src/server/auth.ts index 1f237ac..0d880fd 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -348,12 +348,30 @@ export const getStartupConfigFn = createServerFn({ method: 'GET' }).handler( } const ssoOnly = process.env.ADMIN_SSO_ONLY === 'true'; try { - const response = await fetch(`${getServerApiUrl()}/api/config`); + /** + * Forward the tenant header so LibreChat's `/api/config` route + * (mounted behind `preAuthTenantMiddleware`) resolves tenant-scoped + * `registration.socialLogins` instead of falling back to base config. + */ + const headers: Record = {}; + const tenantId = getRequestHeader('x-tenant-id'); + if (typeof tenantId === 'string' && tenantId.trim().length > 0) { + headers['X-Tenant-Id'] = tenantId.trim(); + } + const response = await fetch(`${getServerApiUrl()}/api/config`, { headers }); if (!response.ok) return { providers: [], ssoOnly }; const config = (await response.json()) as t.StartupConfigResponse; const providers: t.ResolvedProvider[] = []; for (const def of OAUTH_PROVIDERS) { if (config[def.enabledKey as keyof t.StartupConfigResponse] !== true) continue; + /** + * Providers whose LibreChat strategy is registered inside + * `configureSocialLogins` (e.g. google) are only available when the + * upstream `ALLOW_SOCIAL_LOGIN` env is true. Surfacing the button + * otherwise lands users on an "Unknown authentication strategy" 500. + * OpenID has its own registration path and is unaffected. + */ + if (def.social && config.socialLoginEnabled !== true) continue; providers.push({ id: def.id, label: def.labelKey diff --git a/src/types/auth.ts b/src/types/auth.ts index f2c1a19..5341b4e 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -25,6 +25,13 @@ export interface OAuthProviderDef { labelKey?: string; /** /api/config field for a deployer-supplied image URL. */ imageKey?: string; + /** + * Provider whose LibreChat passport strategy is registered inside + * `configureSocialLogins` (gated on `ALLOW_SOCIAL_LOGIN`). For these, + * surfacing the button when `socialLoginEnabled !== true` upstream would + * point users at an "Unknown authentication strategy" 500. + */ + social?: boolean; } export interface ResolvedProvider { From d3b129c8f06920c466818eed3d127be39b5ac2a2 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:17:36 -0700 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=93=9D=20docs:=20Note=20the=20Googl?= =?UTF-8?q?e=20admin=20refresh-token=20gap=20in=20oauthExchangeFn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit comment near the session write in `oauthExchangeFn` documenting why non-openid OAuth admin sessions arrive without a refresh token: LibreChat's `googleAdmin` passport strategy does not request `access_type=offline`, and `createOAuthHandler` only forwards refresh tokens when `provider === 'openid' && OPENID_REUSE_TOKENS=true`. The practical effect is that Google admin users are re-prompted at JWT expiry. A proper fix lives upstream in LibreChat (capture and expose a refresh token for Google admin exchanges). Tracking that as a separate follow-up. --- src/server/auth.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/server/auth.ts b/src/server/auth.ts index 0d880fd..2997731 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -474,6 +474,17 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' }) } const exchangeData = responseData as t.OAuthExchangeResponse; + /** + * Non-openid OAuth admin sessions (currently `google`) arrive without a + * refresh token: LibreChat's `googleAdmin` passport strategy does not + * request `access_type=offline`, and `createOAuthHandler` in + * `api/server/controllers/auth/oauth.js` only forwards refresh tokens + * when `provider === 'openid' && OPENID_REUSE_TOKENS=true`. As a result, + * `verifyAdminTokenFn` cannot transparently refresh these sessions and + * the user is re-prompted at JWT expiry. Resolving this requires an + * upstream LibreChat change to capture and expose a refresh token for + * Google admin exchanges. + */ const now = Date.now(); await session.update({ user: exchangeData.user, From 908ebc638bce5fcaacac3e3160b04f936de5309b Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:18:07 -0700 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=9A=A8=20fix:=20Surface=20upstream?= =?UTF-8?q?=20OAuth=20callback=20errors=20instead=20of=20generic=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibreChat's admin OAuth routes redirect passport/PKCE/auth failures back with `error` and `error_description` query params (e.g. `pkce_store_failed`, `auth_failed`). The callback loaders previously accepted only `code` and treated everything else as `invalid_code`, so a cancelled Google consent or an upstream auth failure surfaced "Authorization code has expired" instead of the real reason. Both google and openid callbacks now accept `error` / `error_description` in their search schemas and render the upstream description verbatim. Falling back to `error` itself keeps the page useful when the upstream redirect omits the description. --- src/routes/auth/google/callback.tsx | 20 ++++++++++++++++++-- src/routes/auth/openid/callback.tsx | 18 ++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/routes/auth/google/callback.tsx b/src/routes/auth/google/callback.tsx index 89ecaee..355f883 100644 --- a/src/routes/auth/google/callback.tsx +++ b/src/routes/auth/google/callback.tsx @@ -5,12 +5,28 @@ import { useLocalize } from '@/hooks'; const searchSchema = z.object({ code: z.string().optional(), + error: z.string().optional(), + error_description: z.string().optional(), }); export const Route = createFileRoute('/auth/google/callback')({ validateSearch: searchSchema, - loaderDeps: ({ search }) => ({ code: search.code }), - loader: async ({ deps: { code } }) => { + loaderDeps: ({ search }) => ({ + code: search.code, + error: search.error, + error_description: search.error_description, + }), + loader: async ({ deps: { code, error, error_description } }) => { + /** + * LibreChat's admin Google route redirects passport/PKCE/auth failures + * back with `error` + `error_description` query params (see + * `api/server/routes/admin/auth.js` `/oauth/google` and + * `/oauth/google/callback`). Surface those instead of falling back to a + * generic "code may have expired" message. + */ + if (error) { + return { error: 'upstream_error' as const, message: error_description ?? error }; + } if (!code || !/^[a-f0-9]{64}$/.test(code)) { return { error: 'invalid_code' as const }; } diff --git a/src/routes/auth/openid/callback.tsx b/src/routes/auth/openid/callback.tsx index 9f4caf9..8266586 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -5,12 +5,26 @@ import { useLocalize } from '@/hooks'; const searchSchema = z.object({ code: z.string().optional(), + error: z.string().optional(), + error_description: z.string().optional(), }); export const Route = createFileRoute('/auth/openid/callback')({ validateSearch: searchSchema, - loaderDeps: ({ search }) => ({ code: search.code }), - loader: async ({ deps: { code } }) => { + loaderDeps: ({ search }) => ({ + code: search.code, + error: search.error, + error_description: search.error_description, + }), + loader: async ({ deps: { code, error, error_description } }) => { + /** + * LibreChat's admin OpenID route redirects passport/PKCE/auth failures + * back with `error` + `error_description` query params. Surface those + * instead of falling back to a generic "code may have expired" message. + */ + if (error) { + return { error: 'upstream_error' as const, message: error_description ?? error }; + } if (!code || !/^[a-f0-9]{64}$/.test(code)) { return { error: 'invalid_code' as const }; } From 429e5467752e2138f3bc8b8d385c7e3559561b6e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:35:00 -0700 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=92=20fix:=20Honor=20ssoOnly=20e?= =?UTF-8?q?ven=20when=20no=20SSO=20providers=20are=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hidePasswordForm` previously included a `providers.length > 0` clause as a defensive fallback, but combined with the new social-login gate that can legitimately leave `providers` empty for an `ADMIN_SSO_ONLY=true` deployment (e.g. only Google configured but upstream `ALLOW_SOCIAL_LOGIN=false`), it leaked the password form back into the page and defeated the deployer's SSO-only intent. `hidePasswordForm` now collapses to `ssoOnly`. When `ssoOnly` is set and discovery returns no providers, `AuthCard` shows a warning banner via a new `com_auth_sso_required_unconfigured` locale key so the misconfigured state is visible instead of silently degrading. --- src/components/AuthCard.tsx | 20 ++++++++++++++++++-- src/locales/en/translation.json | 1 + 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index 450ab4e..624655d 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -33,8 +33,16 @@ export function AuthCard({ const [totpCode, setTotpCode] = useState(''); const showAutoRedirect = !!autoRedirectProvider && !autoRedirectFailed; - /** Hide the password form only when ssoOnly is set AND at least one provider is configured. */ - const hidePasswordForm = ssoOnly && providers.length > 0; + /** + * `ssoOnly` is the deployer's intent ("no password login"). It must hide the + * password form even when SSO discovery returns no providers, otherwise a + * misconfiguration (e.g. ADMIN_SSO_ONLY=true + only Google configured + + * upstream ALLOW_SOCIAL_LOGIN=false) would silently fall back to password + * login and defeat the policy. The unconfigured-SSO banner below surfaces + * the broken state instead. + */ + const hidePasswordForm = ssoOnly; + const ssoOnlyUnconfigured = ssoOnly && providers.length === 0; useEffect(() => { const messages = [generalError, errors.email, errors.password].filter(Boolean); @@ -246,6 +254,14 @@ export function AuthCard({ {generalError && } + {ssoOnlyUnconfigured && step !== '2fa' && ( + + )} + {step === '2fa' ? ( <>

diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2eb73fa..5ec2a50 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -797,6 +797,7 @@ "com_auth_sso_back_to_login": "Back to login", "com_auth_sso_redirecting_auto": "Redirecting to your identity provider...", "com_auth_sso_redirect_failed": "SSO redirect failed. You can sign in manually below.", + "com_auth_sso_required_unconfigured": "SSO is required for admin login, but no SSO provider is currently available. Contact your administrator.", "com_auth_provider_openid": "Continue with OpenID", "com_auth_provider_google": "Continue with Google", "com_error_page_title": "Something went wrong", From 3a0ed8404c4ab369efcdac95fe1338b086d0ff60 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:55:56 -0700 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=8A=20fix:=20Register=20Google?= =?UTF-8?q?=20admin=20callback=20as=20a=20known=20Prometheus=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bun server's metrics wrapper normalizes any path missing from `KNOWN_APP_ROUTES` to `unknown`, so the new Google admin callback was collapsing every login attempt (success or failure) into the same bucket as bot probes and 404s. Adds `/auth/google/callback` to the registry next to the existing openid entry, and to the metrics test matrix so the mapping is locked in. --- src/server/metrics.test.ts | 1 + src/server/metrics.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/server/metrics.test.ts b/src/server/metrics.test.ts index 00cb9ec..4c715af 100644 --- a/src/server/metrics.test.ts +++ b/src/server/metrics.test.ts @@ -7,6 +7,7 @@ describe('normalizeMetricsPath', () => { ['/login', '/login'], ['/configuration/', '/configuration'], ['/auth/openid/callback', '/auth/openid/callback'], + ['/auth/google/callback', '/auth/google/callback'], ])('keeps known app route %s bounded as %s', (input, expected) => { expect(normalizeMetricsPath(input)).toBe(expected); }); diff --git a/src/server/metrics.ts b/src/server/metrics.ts index 67e142b..e4fbe57 100644 --- a/src/server/metrics.ts +++ b/src/server/metrics.ts @@ -12,6 +12,7 @@ const KNOWN_APP_ROUTES = new Map([ ['/help', '/help'], ['/users', '/users'], ['/auth/openid/callback', '/auth/openid/callback'], + ['/auth/google/callback', '/auth/google/callback'], ]); const STATIC_ASSET_RE = From ba2b945db2c82eee66830d23cfc61677d02c2676 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:18:38 -0700 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=92=20fix:=20Respect=20socialLog?= =?UTF-8?q?ins=20allowlist=20and=20preserve=20post-SSO=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup-config loader surfaced any provider whose *LoginEnabled flag was set, ignoring the deployer's socialLogins allowlist the chat client honors, so a provider hidden from social logins still showed an admin button. Provider resolution now also requires membership in socialLogins when that list is present in the config payload. The OAuth callbacks always returned to the dashboard, discarding the deep-link target captured in /login?redirect=. The destination is now stashed in the admin session at login start, rides through the provider round-trip alongside the PKCE verifier, and is restored by the callback loader after a successful exchange, sanitized to a same-origin path to avoid an open redirect. --- src/components/AuthCard.tsx | 10 ++--- src/routes/auth/google/callback.tsx | 2 +- src/routes/auth/openid/callback.tsx | 4 +- src/server/auth.oauth.test.ts | 69 ++++++++++++++++++++++++++++- src/server/auth.ts | 35 ++++++++++++--- src/types/server.ts | 3 ++ src/utils/index.ts | 1 + src/utils/redirect.test.ts | 44 ++++++++++++++++++ src/utils/redirect.ts | 14 ++++++ 9 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 src/utils/redirect.test.ts create mode 100644 src/utils/redirect.ts diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index 624655d..4ed3a5f 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -60,18 +60,14 @@ export function AuthCard({ autoRedirectAttempted.current = true; setPendingProvider(autoRedirectProvider); - oauthLoginFn({ data: { provider: autoRedirectProvider } }) + oauthLoginFn({ data: { provider: autoRedirectProvider, redirectTo } }) .then((result) => { if (result.error || !result.authUrl) { setAutoRedirectFailed(true); setGeneralError(result.message || localize('com_auth_sso_redirect_failed')); return; } - const authUrl = new URL(result.authUrl); - if (redirectTo && redirectTo !== '/') { - authUrl.searchParams.set('redirectTo', redirectTo); - } - window.location.href = authUrl.toString(); + window.location.href = result.authUrl; }) .catch(() => { setAutoRedirectFailed(true); @@ -200,7 +196,7 @@ export function AuthCard({ if (pendingProvider) return; setPendingProvider(provider); try { - const result = await oauthLoginFn({ data: { provider } }); + const result = await oauthLoginFn({ data: { provider, redirectTo } }); if (result.error) { setGeneralError(result.message || localize('com_auth_login_failed')); return; diff --git a/src/routes/auth/google/callback.tsx b/src/routes/auth/google/callback.tsx index 355f883..d461cdf 100644 --- a/src/routes/auth/google/callback.tsx +++ b/src/routes/auth/google/callback.tsx @@ -36,7 +36,7 @@ export const Route = createFileRoute('/auth/google/callback')({ if (result.error) { return { error: 'exchange_failed' as const, message: result.message }; } - throw redirect({ to: '/' }); + throw redirect({ to: result.redirectTo ?? '/' }); } catch (e) { if (e instanceof Response || (e && typeof e === 'object' && 'to' in e)) throw e; return { error: 'exchange_failed' as const }; diff --git a/src/routes/auth/openid/callback.tsx b/src/routes/auth/openid/callback.tsx index 8266586..3a16034 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createFileRoute, redirect, Link } from '@tanstack/react-router'; +import { Link, createFileRoute, redirect } from '@tanstack/react-router'; import { oauthExchangeFn } from '@/server'; import { useLocalize } from '@/hooks'; @@ -34,7 +34,7 @@ export const Route = createFileRoute('/auth/openid/callback')({ if (result.error) { return { error: 'exchange_failed' as const, message: result.message }; } - throw redirect({ to: '/' }); + throw redirect({ to: result.redirectTo ?? '/' }); } catch (e) { if (e instanceof Response || (e && typeof e === 'object' && 'to' in e)) throw e; return { error: 'exchange_failed' as const }; diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 1947d16..eb7c45b 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -44,7 +44,7 @@ vi.mock('./utils/refresh', () => ({ refreshAdminTokenDeduped: vi.fn(), })); -import { getStartupConfigFn, oauthExchangeFn } from './auth'; +import { getStartupConfigFn, oauthExchangeFn, oauthLoginFn } from './auth'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -82,6 +82,7 @@ describe('oauthExchangeFn', () => { expect(result).toEqual({ error: false, user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + redirectTo: '/', }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', { @@ -102,6 +103,54 @@ describe('oauthExchangeFn', () => { ); }); + it('returns the post-login redirect captured at login start and clears it from the session', async () => { + sessionState.data = { codeVerifier: 'verifier-123', postLoginRedirect: '/users' }; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + expiresAt: 123456, + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + const result = await oauthExchangeFn({ + data: { code: 'a'.repeat(64), provider: 'openid' }, + }); + + expect(result).toMatchObject({ error: false, redirectTo: '/users' }); + expect(updateSession).toHaveBeenCalledWith( + expect.objectContaining({ postLoginRedirect: undefined }), + ); + }); + + it('sanitizes an unsafe stored redirect back to the dashboard', async () => { + sessionState.data = { codeVerifier: 'verifier-123', postLoginRedirect: '//evil.com' }; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token', + expiresAt: 123456, + user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' }, + }), + ); + + const result = await oauthExchangeFn({ + data: { code: 'a'.repeat(64), provider: 'openid' }, + }); + + expect(result).toMatchObject({ error: false, redirectTo: '/' }); + }); + + it('stores the sanitized post-login redirect in the session when starting login', async () => { + await oauthLoginFn({ data: { provider: 'google', redirectTo: '/access' } }); + expect(updateSession).toHaveBeenCalledWith( + expect.objectContaining({ postLoginRedirect: '/access' }), + ); + + updateSession.mockClear(); + await oauthLoginFn({ data: { provider: 'google', redirectTo: 'https://evil.com' } }); + expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ postLoginRedirect: '/' })); + }); + it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => { sessionState.data = {}; @@ -192,6 +241,24 @@ describe('getStartupConfigFn', () => { }); }); + it('hides a provider omitted from the socialLogins allowlist even when enabled', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + openidLoginEnabled: true, + googleLoginEnabled: true, + socialLoginEnabled: true, + socialLogins: ['openid'], + }), + ); + + const result = await getStartupConfigFn(); + + expect(result).toEqual({ + providers: [{ id: 'openid', label: undefined, imageUrl: undefined }], + ssoOnly: false, + }); + }); + it('forwards X-Tenant-Id to the LibreChat startup config request', async () => { requestHeaders.set('x-tenant-id', 'tenant-42'); fetchMock.mockResolvedValueOnce(jsonResponse(200, { openidLoginEnabled: true })); diff --git a/src/server/auth.ts b/src/server/auth.ts index 2997731..5b25fa1 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -10,6 +10,7 @@ import { getApiBaseUrl, getServerApiUrl } from './utils/url'; import { refreshAdminTokenDeduped } from './utils/refresh'; import { buildOAuthExchangePayload } from './utils/oauth'; import { useAppSession, SESSION_CONFIG } from './session'; +import { sanitizeInternalRedirect } from '@/utils'; import { OAUTH_PROVIDERS } from '@/constants'; /** Extract a named cookie value from `set-cookie` response headers. */ @@ -361,6 +362,7 @@ export const getStartupConfigFn = createServerFn({ method: 'GET' }).handler( const response = await fetch(`${getServerApiUrl()}/api/config`, { headers }); if (!response.ok) return { providers: [], ssoOnly }; const config = (await response.json()) as t.StartupConfigResponse; + const socialLogins = Array.isArray(config.socialLogins) ? config.socialLogins : undefined; const providers: t.ResolvedProvider[] = []; for (const def of OAUTH_PROVIDERS) { if (config[def.enabledKey as keyof t.StartupConfigResponse] !== true) continue; @@ -372,6 +374,13 @@ export const getStartupConfigFn = createServerFn({ method: 'GET' }).handler( * OpenID has its own registration path and is unaffected. */ if (def.social && config.socialLoginEnabled !== true) continue; + /** + * Honor the deployer's `socialLogins` allowlist the chat client uses to + * decide which buttons to render: a provider omitted from that list is + * hidden even when its *LoginEnabled flag is set. When the list is + * absent the upstream default allows every enabled provider. + */ + if (socialLogins && !socialLogins.includes(def.id)) continue; providers.push({ id: def.id, label: def.labelKey @@ -396,7 +405,10 @@ export const startupConfigOptions = queryOptions({ staleTime: 60_000, }); -async function buildOAuthLoginUrl(provider: t.OAuthProvider): Promise { +async function buildOAuthLoginUrl( + provider: t.OAuthProvider, + redirectTo: string | undefined, +): Promise { const def = OAUTH_PROVIDERS.find((p) => p.id === provider); if (!def) throw new Error(`Unknown OAuth provider: ${provider}`); @@ -406,17 +418,23 @@ async function buildOAuthLoginUrl(provider: t.OAuthProvider): Promise { const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex'); authUrl.searchParams.set('code_challenge', codeChallenge); + /** + * Stash the post-login destination in the admin session so the callback can + * restore it after the provider round-trip, the same way `codeVerifier` rides + * through. Sanitized to a same-origin path to avoid an open redirect. + */ + const postLoginRedirect = sanitizeInternalRedirect(redirectTo); const session = await useAppSession(); - await session.update({ codeVerifier }); + await session.update({ codeVerifier, postLoginRedirect }); return authUrl.toString(); } export const oauthLoginFn = createServerFn({ method: 'POST' }) - .inputValidator(z.object({ provider: oauthProviderSchema })) + .inputValidator(z.object({ provider: oauthProviderSchema, redirectTo: z.string().optional() })) .handler(async ({ data }) => { try { - const authUrl = await buildOAuthLoginUrl(data.provider); + const authUrl = await buildOAuthLoginUrl(data.provider, data.redirectTo); return { error: false as const, authUrl }; } catch (error) { console.error(`[oauthLoginFn] ${data.provider} initiation error:`, error); @@ -438,7 +456,7 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' }) if (requestOrigin) headers['Origin'] = requestOrigin; const session = await useAppSession(); - const { codeVerifier } = session.data; + const { codeVerifier, postLoginRedirect } = session.data; const exchangePayload = buildOAuthExchangePayload(data.code, codeVerifier); if (!exchangePayload.ok) { console.warn( @@ -495,9 +513,14 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' }) lastVerified: now, lastActivity: now, codeVerifier: undefined, + postLoginRedirect: undefined, }); - return { error: false, user: exchangeData.user }; + return { + error: false, + user: exchangeData.user, + redirectTo: sanitizeInternalRedirect(postLoginRedirect), + }; } catch (error) { console.error('OAuth exchange error:', error); return { error: true, message: 'Failed to complete authentication. Please try again.' }; diff --git a/src/types/server.ts b/src/types/server.ts index eb42836..8855653 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -13,6 +13,8 @@ export interface SessionData { lastVerified?: number; lastActivity?: number; codeVerifier?: string; + /** In-app path to return to after a successful OAuth exchange, captured at login start. */ + postLoginRedirect?: string; } export interface StartupConfigResponse { @@ -24,6 +26,7 @@ export interface StartupConfigResponse { appleLoginEnabled?: boolean; samlLoginEnabled?: boolean; socialLoginEnabled?: boolean; + socialLogins?: string[]; openidLabel?: string; openidImageUrl?: string; openidAutoRedirect?: boolean; diff --git a/src/utils/index.ts b/src/utils/index.ts index 4f887f9..2ecff4d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,4 +2,5 @@ export * from './capabilities'; export * from './cn'; export * from './format'; export * from './interfacePermissions'; +export * from './redirect'; export * from './toast'; diff --git a/src/utils/redirect.test.ts b/src/utils/redirect.test.ts new file mode 100644 index 0000000..4164ed9 --- /dev/null +++ b/src/utils/redirect.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { isSafeInternalPath, sanitizeInternalRedirect } from './redirect'; + +describe('isSafeInternalPath', () => { + it('accepts same-origin absolute paths', () => { + for (const ok of ['/', '/users', '/access?tab=roles', '/a/b/c']) { + expect(isSafeInternalPath(ok)).toBe(true); + } + }); + + it('rejects open-redirect and non-path payloads', () => { + for (const bad of [ + '//evil.com', + '/\\evil.com', + 'https://evil.com', + 'evil.com', + '', + undefined, + null, + ]) { + expect(isSafeInternalPath(bad)).toBe(false); + } + }); +}); + +describe('sanitizeInternalRedirect', () => { + it('passes through a safe path unchanged', () => { + expect(sanitizeInternalRedirect('/users')).toBe('/users'); + }); + + it('falls back to "/" for unsafe or missing targets', () => { + for (const bad of [ + '//evil.com', + '/\\evil.com', + 'https://evil.com', + 'evil.com', + '', + undefined, + null, + ]) { + expect(sanitizeInternalRedirect(bad)).toBe('/'); + } + }); +}); diff --git a/src/utils/redirect.ts b/src/utils/redirect.ts new file mode 100644 index 0000000..73583db --- /dev/null +++ b/src/utils/redirect.ts @@ -0,0 +1,14 @@ +/** + * Accept only same-origin absolute paths so a stored post-login redirect can't + * be abused as an open redirect. Rejects protocol-relative (`//host`) and + * backslash (`/\host`) forms that browsers may normalize to another origin. + */ +export function isSafeInternalPath(target: string | undefined | null): target is string { + if (!target || target[0] !== '/') return false; + return target[1] !== '/' && target[1] !== '\\'; +} + +/** Normalize an untrusted redirect target to a safe in-app path, defaulting to "/". */ +export function sanitizeInternalRedirect(target: string | undefined | null): string { + return isSafeInternalPath(target) ? target : '/'; +}