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..624655d 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,11 +56,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 +77,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 +196,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 +211,7 @@ export function AuthCard({ } catch { setGeneralError(localize('com_auth_unable_connect')); } finally { - setSsoLoading(false); + setPendingProvider(undefined); } }; @@ -231,6 +236,8 @@ export function AuthCard({ ); } + const showPasswordForm = !hidePasswordForm; + return ( } + {ssoOnlyUnconfigured && step !== '2fa' && ( + + )} + {step === '2fa' ? ( <>

@@ -292,52 +307,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 08131e7..eec598c 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,4 @@ 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 9a52c19..365ae29 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -758,6 +758,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..355f883 --- /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: '/' }); + } 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..8266586 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -5,18 +5,32 @@ 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 }; } 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..1947d16 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 } from './auth'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -75,7 +75,9 @@ 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, @@ -103,7 +105,9 @@ describe('oauthExchangeFn', () => { 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 +121,7 @@ describe('oauthExchangeFn', () => { }); }); -describe('checkOpenIdFn', () => { +describe('getStartupConfigFn', () => { const originalSsoEnabled = process.env.ADMIN_SSO_ENABLED; const originalSsoOnly = process.env.ADMIN_SSO_ONLY; @@ -136,30 +140,75 @@ 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 getStartupConfigFn(); - const result = await checkOpenIdFn(); + expect(result).toEqual({ + providers: [{ id: 'openid', label: undefined, imageUrl: undefined }], + ssoOnly: true, + }); + }); - expect(result).toEqual({ available: true, 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('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 +216,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 bf145c9..12bf557 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -7,9 +7,10 @@ import { createServerFn } from '@tanstack/react-start'; import { getRequestHeader } from '@tanstack/react-start/server'; import type * as t from '@/types'; import { getApiBaseUrl, getServerApiUrl } from './utils/url'; -import { buildOAuthExchangePayload } from './utils/oauth'; import { refreshAdminTokenDeduped } from './utils/refresh'; +import { buildOAuthExchangePayload } from './utils/oauth'; import { useAppSession, SESSION_CONFIG } from './session'; +import { OAUTH_PROVIDERS } from '@/constants'; /** Extract a named cookie value from `set-cookie` response headers. */ function extractCookieValue(response: Response, name: string): string | undefined { @@ -320,54 +321,108 @@ 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 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 + ? (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 }; + } + }, +); + +/** Shared queryOptions so consumers deduplicate the startup-config fetch. */ +export const startupConfigOptions = queryOptions({ + queryKey: ['adminStartupConfig'], + queryFn: () => getStartupConfigFn(), + staleTime: 60_000, }); -export const openidLoginFn = createServerFn({ method: 'GET' }).handler(async () => { - try { - const baseUrl = getApiBaseUrl(); - const authUrl = new URL(`${baseUrl}/api/admin/oauth/openid`); +async function buildOAuthLoginUrl(provider: t.OAuthProvider): Promise { + const def = OAUTH_PROVIDERS.find((p) => p.id === provider); + if (!def) throw new Error(`Unknown OAuth provider: ${provider}`); - const codeVerifier = crypto.randomBytes(32).toString('hex'); - const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex'); - authUrl.searchParams.set('code_challenge', codeChallenge); + const authUrl = new URL(`${getApiBaseUrl()}${def.startPath}`); - const session = await useAppSession(); - await session.update({ codeVerifier }); + const codeVerifier = crypto.randomBytes(32).toString('hex'); + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex'); + authUrl.searchParams.set('code_challenge', codeChallenge); - return { error: false, authUrl: authUrl.toString() }; - } catch (error) { - console.error('OpenID login initiation error:', error); - return { error: true, message: 'Failed to initiate SSO login' }; - } -}); + const session = await useAppSession(); + await session.update({ codeVerifier }); + + return authUrl.toString(); +} + +export const oauthLoginFn = createServerFn({ method: 'POST' }) + .inputValidator(z.object({ provider: oauthProviderSchema })) + .handler(async ({ data }) => { + try { + const authUrl = await buildOAuthLoginUrl(data.provider); + 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 { @@ -412,12 +467,23 @@ 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, 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..eb42836 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,7 +7,7 @@ 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; @@ -14,6 +15,27 @@ export interface SessionData { codeVerifier?: string; } +export interface StartupConfigResponse { + openidLoginEnabled?: boolean; + googleLoginEnabled?: boolean; + githubLoginEnabled?: boolean; + discordLoginEnabled?: boolean; + facebookLoginEnabled?: boolean; + appleLoginEnabled?: boolean; + samlLoginEnabled?: boolean; + socialLoginEnabled?: boolean; + openidLabel?: string; + openidImageUrl?: string; + openidAutoRedirect?: boolean; + samlLabel?: string; + samlImageUrl?: string; +} + +export interface AdminStartupConfig { + providers: ResolvedProvider[]; + ssoOnly: boolean; +} + export interface AdminLoginResponse { token: string; user: SerializableUser;