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