From 9ab4f6ad4b32f88a85b53a41f6575a129ae69864 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 1/9] =?UTF-8?q?=F0=9F=94=90=20feat:=20Add=20Google=20OAuth?= =?UTF-8?q?=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 | 117 +++++++++++------- src/types/auth.ts | 35 +++++- src/types/server.ts | 24 +++- 15 files changed, 479 insertions(+), 148 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 766f00063cdd49e21eec3cc3e60c88fab9ecd6e2 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 3/9] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Honor=20router=20base?= =?UTF-8?q?path=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 bde4844072563731ead7df795d0571e385260d7e 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 4/9] =?UTF-8?q?=F0=9F=8E=AD=20fix:=20Point=20Playwright=20?= =?UTF-8?q?mock-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 936089734db5864a2b9cc5906072d331a27011e5 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 5/9] =?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 2466198..a7382d4 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -341,12 +341,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 80c7474f2e039b40d1a979cfcd386d2dc82d7f3f 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 6/9] =?UTF-8?q?=F0=9F=93=9D=20docs:=20Note=20the=20Google?= =?UTF-8?q?=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 a7382d4..12bf557 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -467,6 +467,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 f4eb8bece1fa34d5301b8f25f8871211975b1ddc 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 7/9] =?UTF-8?q?=F0=9F=9A=A8=20fix:=20Surface=20upstream=20?= =?UTF-8?q?OAuth=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 115433a9c40a96ea36fdb27261af50a75174afdd 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 8/9] =?UTF-8?q?=F0=9F=94=92=20fix:=20Honor=20ssoOnly=20eve?= =?UTF-8?q?n=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 a0d6d5a..365ae29 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -758,6 +758,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 8463b6677ad57df6c1c03894e845fb69b489ce6c 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 9/9] =?UTF-8?q?=F0=9F=93=8A=20fix:=20Register=20Google=20a?= =?UTF-8?q?dmin=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 =