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/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, }, diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index 756dd65..4ed3a5f 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -1,19 +1,20 @@ 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 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 { OAuthButton } from './OAuthButton'; import { useLocalize } from '@/hooks'; export function AuthCard({ redirectTo = '/', - autoRedirectSso = false, - ssoAvailable: ssoAvailableProp, + providers = [], + ssoOnly = false, + autoRedirectProvider, }: t.AuthCardProps) { const router = useRouter(); const localize = useLocalize(); @@ -24,20 +25,24 @@ 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; + /** + * `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); @@ -51,29 +56,25 @@ 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, 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); 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 +192,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, redirectTo } }); if (result.error) { setGeneralError(result.message || localize('com_auth_login_failed')); return; @@ -206,7 +207,7 @@ export function AuthCard({ } catch { setGeneralError(localize('com_auth_unable_connect')); } finally { - setSsoLoading(false); + setPendingProvider(undefined); } }; @@ -231,6 +232,8 @@ export function AuthCard({ ); } + const showPasswordForm = !hidePasswordForm; + return ( } + {ssoOnlyUnconfigured && step !== '2fa' && ( + + )} + {step === '2fa' ? ( <>

@@ -292,52 +303,57 @@ export function AuthCard({ ) : ( <> - { - 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} - /> - - + ); +} diff --git a/src/constants/index.ts b/src/constants/index.ts index fa847c2..d2186fa 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,5 @@ export * from './audit'; export * from './capabilities'; +export * from './oauth'; export * from './role'; export * from './scope'; diff --git a/src/constants/oauth.ts b/src/constants/oauth.ts new file mode 100644 index 0000000..ef7291f --- /dev/null +++ b/src/constants/oauth.ts @@ -0,0 +1,29 @@ +import type * as t from '@/types'; + +/** + * Registry of OAuth providers the admin panel knows how to surface. + * + * Adding a new provider that LibreChat already supports (e.g. github, discord) + * is a matter of appending an entry here, adding a callback route file at + * `src/routes/auth//callback.tsx`, and adding the matching i18n key. + */ +export const OAUTH_PROVIDERS: ReadonlyArray = [ + { + id: 'openid', + startPath: '/api/admin/oauth/openid', + callbackRoute: '/auth/openid/callback', + defaultLabelKey: 'com_auth_provider_openid', + enabledKey: 'openidLoginEnabled', + labelKey: 'openidLabel', + imageKey: 'openidImageUrl', + }, + { + id: 'google', + startPath: '/api/admin/oauth/google', + callbackRoute: '/auth/google/callback', + defaultLabelKey: 'com_auth_provider_google', + logo: 'google', + enabledKey: 'googleLoginEnabled', + social: true, + }, +]; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9f4e7f8..5ec2a50 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -797,6 +797,9 @@ "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", "com_error_page_desc": "An unexpected error occurred. Please try again or return to the home page.", "com_error_not_found_title": "Page not found", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5a00389..9a94d07 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as AppGrantsRouteImport } from './routes/_app/grants' import { Route as AppAccessRouteImport } from './routes/_app/access' import { Route as AppConfigurationIndexRouteImport } from './routes/_app/configuration/index' import { Route as AuthOpenidCallbackRouteImport } from './routes/auth/openid/callback' +import { Route as AuthGoogleCallbackRouteImport } from './routes/auth/google/callback' const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -63,6 +64,11 @@ const AuthOpenidCallbackRoute = AuthOpenidCallbackRouteImport.update({ path: '/auth/openid/callback', getParentRoute: () => rootRouteImport, } as any) +const AuthGoogleCallbackRoute = AuthGoogleCallbackRouteImport.update({ + id: '/auth/google/callback', + path: '/auth/google/callback', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof AppIndexRoute @@ -71,6 +77,7 @@ export interface FileRoutesByFullPath { '/grants': typeof AppGrantsRoute '/help': typeof AppHelpRoute '/users': typeof AppUsersRoute + '/auth/google/callback': typeof AuthGoogleCallbackRoute '/auth/openid/callback': typeof AuthOpenidCallbackRoute '/configuration/': typeof AppConfigurationIndexRoute } @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/help': typeof AppHelpRoute '/users': typeof AppUsersRoute '/': typeof AppIndexRoute + '/auth/google/callback': typeof AuthGoogleCallbackRoute '/auth/openid/callback': typeof AuthOpenidCallbackRoute '/configuration': typeof AppConfigurationIndexRoute } @@ -93,6 +101,7 @@ export interface FileRoutesById { '/_app/help': typeof AppHelpRoute '/_app/users': typeof AppUsersRoute '/_app/': typeof AppIndexRoute + '/auth/google/callback': typeof AuthGoogleCallbackRoute '/auth/openid/callback': typeof AuthOpenidCallbackRoute '/_app/configuration/': typeof AppConfigurationIndexRoute } @@ -105,6 +114,7 @@ export interface FileRouteTypes { | '/grants' | '/help' | '/users' + | '/auth/google/callback' | '/auth/openid/callback' | '/configuration/' fileRoutesByTo: FileRoutesByTo @@ -115,6 +125,7 @@ export interface FileRouteTypes { | '/help' | '/users' | '/' + | '/auth/google/callback' | '/auth/openid/callback' | '/configuration' id: @@ -126,6 +137,7 @@ export interface FileRouteTypes { | '/_app/help' | '/_app/users' | '/_app/' + | '/auth/google/callback' | '/auth/openid/callback' | '/_app/configuration/' fileRoutesById: FileRoutesById @@ -133,6 +145,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren LoginRoute: typeof LoginRoute + AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute AuthOpenidCallbackRoute: typeof AuthOpenidCallbackRoute } @@ -201,6 +214,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthOpenidCallbackRouteImport parentRoute: typeof rootRouteImport } + '/auth/google/callback': { + id: '/auth/google/callback' + path: '/auth/google/callback' + fullPath: '/auth/google/callback' + preLoaderRoute: typeof AuthGoogleCallbackRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -227,6 +247,7 @@ const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, LoginRoute: LoginRoute, + AuthGoogleCallbackRoute: AuthGoogleCallbackRoute, AuthOpenidCallbackRoute: AuthOpenidCallbackRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/auth/google/callback.tsx b/src/routes/auth/google/callback.tsx new file mode 100644 index 0000000..d461cdf --- /dev/null +++ b/src/routes/auth/google/callback.tsx @@ -0,0 +1,91 @@ +import { z } from 'zod'; +import { Link, createFileRoute, redirect } from '@tanstack/react-router'; +import { oauthExchangeFn } from '@/server'; +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, + 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 }; + } + + try { + const result = await oauthExchangeFn({ data: { code, provider: 'google' } }); + if (result.error) { + return { error: 'exchange_failed' as const, message: result.message }; + } + 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 }; + } + }, + component: GoogleCallback, +}); + +function GoogleCallback() { + const loaderData = Route.useLoaderData(); + const localize = useLocalize(); + + const errorMessage = + loaderData.error === 'invalid_code' + ? localize('com_auth_sso_exchange_failed') + : (loaderData.message ?? localize('com_auth_sso_exchange_failed')); + + return ( +

+
+
+ +
+

+ {localize('com_auth_sso_error_title')} +

+

{errorMessage}

+ + {localize('com_auth_sso_back_to_login')} + +
+
+ ); +} diff --git a/src/routes/auth/openid/callback.tsx b/src/routes/auth/openid/callback.tsx index 4472304..3a16034 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -1,26 +1,40 @@ 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'; 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 }; } try { - const result = await oauthExchangeFn({ data: { code } }); + const result = await oauthExchangeFn({ data: { code, provider: 'openid' } }); 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/login.tsx b/src/routes/login.tsx index 7846efc..14ebb1d 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -2,25 +2,29 @@ import { Container } from '@clickhouse/click-ui'; import { createFileRoute } from '@tanstack/react-router'; import ThemeSelector from '@/components/ThemeSelector'; import { AuthCard } from '@/components/AuthCard'; -import { checkOpenIdFn } from '@/server'; +import { getStartupConfigFn } from '@/server'; export const Route = createFileRoute('/login')({ validateSearch: (search: Record) => ({ redirect: typeof search.redirect === 'string' ? search.redirect : '/', }), loader: async () => { - const openIdStatus = await checkOpenIdFn(); - return { - ssoAvailable: openIdStatus.available, - ssoOnly: openIdStatus.available && openIdStatus.ssoOnly, - }; + const { providers, ssoOnly } = await getStartupConfigFn(); + /** + * Auto-redirect only when ssoOnly is set AND exactly one SSO provider is + * configured. With multiple providers, render all buttons and let the + * admin pick — auto-redirecting to a single one would be ambiguous. + */ + const autoRedirectProvider = + ssoOnly && providers.length === 1 ? providers[0].id : undefined; + return { providers, ssoOnly, autoRedirectProvider }; }, component: LoginPage, }); function LoginPage() { const { redirect } = Route.useSearch(); - const { ssoAvailable, ssoOnly } = Route.useLoaderData(); + const { providers, ssoOnly, autoRedirectProvider } = Route.useLoaderData(); return ( - +
diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 21b9db2..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 { checkOpenIdFn, oauthExchangeFn } from './auth'; +import { getStartupConfigFn, oauthExchangeFn, oauthLoginFn } from './auth'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -75,11 +75,14 @@ describe('oauthExchangeFn', () => { }), ); - const result = await oauthExchangeFn({ data: { code: 'a'.repeat(64) } }); + const result = await oauthExchangeFn({ + data: { code: 'a'.repeat(64), provider: 'openid' }, + }); 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', { @@ -100,10 +103,60 @@ 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 = {}; - const result = await oauthExchangeFn({ data: { code: 'b'.repeat(64) } }); + const result = await oauthExchangeFn({ + data: { code: 'b'.repeat(64), provider: 'openid' }, + }); expect(result).toEqual({ error: true, @@ -117,7 +170,7 @@ describe('oauthExchangeFn', () => { }); }); -describe('checkOpenIdFn', () => { +describe('getStartupConfigFn', () => { const originalSsoEnabled = process.env.ADMIN_SSO_ENABLED; const originalSsoOnly = process.env.ADMIN_SSO_ONLY; @@ -136,30 +189,93 @@ describe('checkOpenIdFn', () => { else process.env.ADMIN_SSO_ONLY = originalSsoOnly; }); - it('reports SSO available with auto-redirect off by default', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse(200, {})); + it('lists each LibreChat-enabled provider with branding overrides', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + openidLoginEnabled: true, + googleLoginEnabled: true, + socialLoginEnabled: true, + openidLabel: 'Corp SSO', + openidImageUrl: 'https://corp.example/logo.png', + }), + ); - const result = await checkOpenIdFn(); + const result = await getStartupConfigFn(); - expect(result).toEqual({ available: true, ssoOnly: false }); - expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/openid/check'); + expect(result).toEqual({ + providers: [ + { id: 'openid', label: 'Corp SSO', imageUrl: 'https://corp.example/logo.png' }, + { id: 'google', label: undefined, imageUrl: undefined }, + ], + ssoOnly: false, + }); + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/config', { headers: {} }); }); it('marks the session SSO-only when ADMIN_SSO_ONLY=true', async () => { process.env.ADMIN_SSO_ONLY = 'true'; - fetchMock.mockResolvedValueOnce(jsonResponse(200, {})); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { openidLoginEnabled: true })); - const result = await checkOpenIdFn(); + const result = await getStartupConfigFn(); - expect(result).toEqual({ available: true, ssoOnly: true }); + expect(result).toEqual({ + providers: [{ id: 'openid', label: undefined, imageUrl: undefined }], + ssoOnly: true, + }); + }); + + 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('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 })); + + await getStartupConfigFn(); + + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/config', { + headers: { 'X-Tenant-Id': 'tenant-42' }, + }); }); - it('hides the SSO button without calling the backend when ADMIN_SSO_ENABLED=false', async () => { + it('hides every SSO provider without calling the backend when ADMIN_SSO_ENABLED=false', async () => { process.env.ADMIN_SSO_ENABLED = 'false'; - const result = await checkOpenIdFn(); + const result = await getStartupConfigFn(); - expect(result).toEqual({ available: false, ssoOnly: false }); + expect(result).toEqual({ providers: [], ssoOnly: false }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -167,17 +283,17 @@ describe('checkOpenIdFn', () => { process.env.ADMIN_SSO_ENABLED = 'false'; process.env.ADMIN_SSO_ONLY = 'true'; - const result = await checkOpenIdFn(); + const result = await getStartupConfigFn(); - expect(result).toEqual({ available: false, ssoOnly: false }); + expect(result).toEqual({ providers: [], ssoOnly: false }); expect(fetchMock).not.toHaveBeenCalled(); }); - it('reports SSO unavailable when the backend check fails', async () => { + it('returns an empty provider list when the startup config request fails', async () => { fetchMock.mockResolvedValueOnce(jsonResponse(503, {})); - const result = await checkOpenIdFn(); + const result = await getStartupConfigFn(); - expect(result).toEqual({ available: false, ssoOnly: false }); + expect(result).toEqual({ providers: [], ssoOnly: false }); }); }); diff --git a/src/server/auth.ts b/src/server/auth.ts index ffd37df..5b25fa1 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -10,6 +10,8 @@ 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. */ function extractCookieValue(response: Response, name: string): string | undefined { @@ -327,54 +329,125 @@ export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler(async }; }); -/** Shared queryOptions so consumers deduplicate the OpenID availability check. */ -export const openIdCheckOptions = queryOptions({ - queryKey: ['openIdCheck'], - queryFn: () => checkOpenIdFn(), - staleTime: 60_000, -}); - -export const checkOpenIdFn = createServerFn({ method: 'GET' }).handler(async () => { - if (process.env.ADMIN_SSO_ENABLED === 'false') { - return { available: false, ssoOnly: false }; - } - const checkUrl = `${getServerApiUrl()}/api/admin/oauth/openid/check`; - try { - const response = await fetch(checkUrl); - if (!response.ok) { - console.warn('[checkOpenIdFn] OpenID check failed:', response.status, checkUrl); - return { available: false, ssoOnly: false }; +const oauthProviderSchema = z.enum(['openid', 'google']); + +/** + * Resolve which OAuth providers LibreChat has configured by reading the public + * /api/config startup payload — the same endpoint LibreChat's own client uses to + * decide which social-login buttons to render. Provider availability is derived + * from the boolean *LoginEnabled flags; deployer-supplied label/imageUrl + * overrides are forwarded for the providers that support them (openid, saml). + * + * ssoOnly is independent of LibreChat: it remains an admin-panel-side knob + * (`ADMIN_SSO_ONLY`) so admins can keep a password fallback even when chat + * users are auto-redirected. + */ +export const getStartupConfigFn = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + if (process.env.ADMIN_SSO_ENABLED === 'false') { + return { providers: [], ssoOnly: false }; } const ssoOnly = process.env.ADMIN_SSO_ONLY === 'true'; - return { available: true, ssoOnly }; - } catch (error) { - console.warn('[checkOpenIdFn] OpenID check request failed:', checkUrl, error); - return { available: false, ssoOnly: false }; - } -}); + try { + /** + * 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 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; + /** + * 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; + /** + * 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 + ? (config[def.labelKey as keyof t.StartupConfigResponse] as string | undefined) + : undefined, + imageUrl: def.imageKey + ? (config[def.imageKey as keyof t.StartupConfigResponse] as string | undefined) + : undefined, + }); + } + return { providers, ssoOnly }; + } catch { + return { providers: [], ssoOnly }; + } + }, +); -export const openidLoginFn = createServerFn({ method: 'GET' }).handler(async () => { - try { - const baseUrl = getApiBaseUrl(); - const authUrl = new URL(`${baseUrl}/api/admin/oauth/openid`); +/** Shared queryOptions so consumers deduplicate the startup-config fetch. */ +export const startupConfigOptions = queryOptions({ + queryKey: ['adminStartupConfig'], + queryFn: () => getStartupConfigFn(), + staleTime: 60_000, +}); - const codeVerifier = crypto.randomBytes(32).toString('hex'); - const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex'); - authUrl.searchParams.set('code_challenge', codeChallenge); +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}`); + + const authUrl = new URL(`${getApiBaseUrl()}${def.startPath}`); + + const codeVerifier = crypto.randomBytes(32).toString('hex'); + 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, postLoginRedirect }); - const session = await useAppSession(); - await session.update({ codeVerifier }); + return authUrl.toString(); +} - return { error: false, authUrl: authUrl.toString() }; - } catch (error) { - console.error('OpenID login initiation error:', error); - return { error: true, message: 'Failed to initiate SSO login' }; - } -}); +export const oauthLoginFn = createServerFn({ method: 'POST' }) + .inputValidator(z.object({ provider: oauthProviderSchema, redirectTo: z.string().optional() })) + .handler(async ({ data }) => { + try { + const authUrl = await buildOAuthLoginUrl(data.provider, data.redirectTo); + return { error: false as const, authUrl }; + } catch (error) { + console.error(`[oauthLoginFn] ${data.provider} initiation error:`, error); + return { error: true as const, message: 'Failed to initiate SSO login' }; + } + }); export const oauthExchangeFn = createServerFn({ method: 'POST' }) .inputValidator( - z.object({ code: z.string().regex(/^[a-f0-9]{64}$/, 'Invalid exchange code format') }), + z.object({ + code: z.string().regex(/^[a-f0-9]{64}$/, 'Invalid exchange code format'), + provider: oauthProviderSchema, + }), ) .handler(async ({ data }) => { try { @@ -383,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( @@ -419,19 +492,35 @@ 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, token: exchangeData.token, refreshToken: exchangeData.refreshToken ?? extractCookieValue(response, 'refreshToken'), - tokenProvider: 'openid', + tokenProvider: data.provider, expiresAt: exchangeData.expiresAt, 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/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 = diff --git a/src/types/auth.ts b/src/types/auth.ts index c6eef17..5341b4e 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,3 +1,5 @@ +import type { LogoName } from '@clickhouse/click-ui'; + export type FieldErrors = { email?: string; password?: string; @@ -5,8 +7,44 @@ export type FieldErrors = { export type AuthStep = 'login' | '2fa'; +export type OAuthProvider = 'openid' | 'google'; + +export interface OAuthProviderDef { + id: OAuthProvider; + /** LibreChat path that initiates this provider's OAuth flow. */ + startPath: string; + /** Admin-panel route that receives the exchange code. */ + callbackRoute: string; + /** i18n key used as the button label when /api/config does not supply one. */ + defaultLabelKey: string; + /** click-ui Logo name. Omitted for non-branded providers (e.g. generic OpenID). */ + logo?: LogoName; + /** /api/config field that signals availability. */ + enabledKey: string; + /** /api/config field for a deployer-supplied label override. */ + 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 { + id: OAuthProvider; + label?: string; + imageUrl?: string; +} + export interface AuthCardProps { redirectTo?: string; - autoRedirectSso?: boolean; - ssoAvailable?: boolean; + providers?: ResolvedProvider[]; + /** When true and at least one SSO provider is configured, hides the password form. */ + ssoOnly?: boolean; + /** Set only when ssoOnly is true and exactly one SSO provider is configured. */ + autoRedirectProvider?: OAuthProvider; } diff --git a/src/types/server.ts b/src/types/server.ts index 74ac34e..8855653 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -1,4 +1,5 @@ import type { TUser } from 'librechat-data-provider'; +import type { OAuthProvider, ResolvedProvider } from './auth'; export type SerializableUser = Pick; @@ -6,12 +7,36 @@ export interface SessionData { user?: SerializableUser; token?: string; refreshToken?: string; - tokenProvider?: 'librechat' | 'openid'; + tokenProvider?: 'librechat' | OAuthProvider; /** Absolute expiry of `token` (ms epoch). Drives proactive refresh. */ expiresAt?: number; 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 { + openidLoginEnabled?: boolean; + googleLoginEnabled?: boolean; + githubLoginEnabled?: boolean; + discordLoginEnabled?: boolean; + facebookLoginEnabled?: boolean; + appleLoginEnabled?: boolean; + samlLoginEnabled?: boolean; + socialLoginEnabled?: boolean; + socialLogins?: string[]; + openidLabel?: string; + openidImageUrl?: string; + openidAutoRedirect?: boolean; + samlLabel?: string; + samlImageUrl?: string; +} + +export interface AdminStartupConfig { + providers: ResolvedProvider[]; + ssoOnly: boolean; } export interface AdminLoginResponse { 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 : '/'; +}