= {};
+ 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 =