From a6adc61627b3171f35f74f5ce4ca9d85a4055f41 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 01:05:40 -0700 Subject: [PATCH] feat(auth): sign in through a trusted proxy and let those users set a password On load, a signed-out visitor first asks the backend to sign them in as the user an authenticating proxy vouches for, before guest mode or a login redirect takes over; an explicit logout skips this for the rest of the tab. Settings gains a password card for signed-in users, where a proxy-signed-in user can set a password without the current one to log in without the proxy. --- frontend/src/App.tsx | 11 ++- .../settings/AccountPasswordCard.tsx | 39 ++++++++++ .../users/ChangePasswordDialog.test.tsx | 54 +++++++++++++ .../components/users/ChangePasswordDialog.tsx | 20 ++++- frontend/src/contexts/AuthContext.tsx | 3 + frontend/src/hooks/useTrustedLogin.test.tsx | 76 +++++++++++++++++++ frontend/src/hooks/useTrustedLogin.ts | 53 +++++++++++++ frontend/src/lib/api/auth.ts | 6 ++ frontend/src/lib/trustedLogin.ts | 5 ++ frontend/src/store/auth.ts | 10 ++- frontend/src/views/SettingsView.tsx | 3 + 11 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/settings/AccountPasswordCard.tsx create mode 100644 frontend/src/components/users/ChangePasswordDialog.test.tsx create mode 100644 frontend/src/hooks/useTrustedLogin.test.tsx create mode 100644 frontend/src/hooks/useTrustedLogin.ts create mode 100644 frontend/src/lib/trustedLogin.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2bd4f86..e1d8a17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { useAuthStore } from './store/auth'; import { useServerStatusStore } from './store/serverStatus'; import { authSettingsApi, authApi } from './lib/api'; import { usePublicSettings } from './hooks/usePublicSettings'; +import { useTrustedLogin } from './hooks/useTrustedLogin'; import { Toaster } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -99,6 +100,8 @@ function App() { const location = useLocation(); const { data: publicSettings } = usePublicSettings(); + const trustedLoginChecked = useTrustedLogin(); + // Check if system needs initial setup (no users exist) const { data: setupStatus } = useQuery({ queryKey: ['setupStatus'], @@ -137,6 +140,8 @@ function App() { location.pathname === '/register' || location.pathname === '/setup'; + if (!trustedLoginChecked) return; + if (isAuthPage && isGuest && !isAuthenticated) { // Clear guest mode when navigating to auth pages clearAuth(); @@ -144,7 +149,7 @@ function App() { // Initialize guest mode for non-auth pages setGuestMode(); } - }, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth]); + }, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth, trustedLoginChecked]); // Update browser title from site name in public settings useEffect(() => { @@ -190,6 +195,10 @@ function App() { ); } + if (!trustedLoginChecked) { + return ; + } + return ( diff --git a/frontend/src/components/settings/AccountPasswordCard.tsx b/frontend/src/components/settings/AccountPasswordCard.tsx new file mode 100644 index 0000000..fedb6c0 --- /dev/null +++ b/frontend/src/components/settings/AccountPasswordCard.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { KeyRound } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog'; +import { useAuthStore } from '@/store/auth'; + +/** Lets a signed-in user change their password, or set one to sign in without single sign-on. */ +export function AccountPasswordCard() { + const user = useAuthStore((state) => state.user); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isTrustedSession = useAuthStore((state) => state.isTrustedSession); + const [isOpen, setIsOpen] = useState(false); + + if (!user || !isAuthenticated) { + return null; + } + + return ( +
+

Password

+

+ {isTrustedSession + ? "You're signed in through single sign-on. Set a password to also sign in without it, such as on your local network." + : 'Change the password you sign in with.'} +

+ + setIsOpen(false)} + onSuccess={() => setIsOpen(false)} + /> +
+ ); +} diff --git a/frontend/src/components/users/ChangePasswordDialog.test.tsx b/frontend/src/components/users/ChangePasswordDialog.test.tsx new file mode 100644 index 0000000..3c5bd06 --- /dev/null +++ b/frontend/src/components/users/ChangePasswordDialog.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChangePasswordDialog } from './ChangePasswordDialog'; + +const mutateAsync = vi.hoisted(() => vi.fn()); +vi.mock('@/hooks/useUsers', () => ({ + useChangePassword: () => ({ mutateAsync, isError: false, isPending: false, error: null }), +})); + +const user = { id: 7, username: 'brad' }; + +async function submit(newPassword: string) { + await userEvent.type(screen.getByLabelText('New Password'), newPassword); + await userEvent.type(screen.getByLabelText('Confirm New Password'), newPassword); + await userEvent.click(screen.getByRole('button', { name: 'Change Password' })); +} + +describe('ChangePasswordDialog', () => { + beforeEach(() => { + mutateAsync.mockReset().mockResolvedValue(undefined); + }); + + it('requires the current password by default', async () => { + render(); + + await submit('new-password'); + + expect(await screen.findByText('Current password is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('lets a single sign-on session set a password without the current one', async () => { + render( + + ); + expect(screen.getByLabelText('Current Password (optional)')).toBeInTheDocument(); + + await submit('new-password'); + + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith({ + id: 7, + passwordData: { currentPassword: undefined, newPassword: 'new-password' }, + }) + ); + }); +}); diff --git a/frontend/src/components/users/ChangePasswordDialog.tsx b/frontend/src/components/users/ChangePasswordDialog.tsx index c4f6048..aa12248 100644 --- a/frontend/src/components/users/ChangePasswordDialog.tsx +++ b/frontend/src/components/users/ChangePasswordDialog.tsx @@ -10,6 +10,7 @@ import { FormControl, FormField, FormItem, + FormDescription, FormLabel, FormMessage, } from '@/components/ui/form'; @@ -45,8 +46,10 @@ type ChangePasswordFormValues = z.infer; interface ChangePasswordDialogProps { isOpen: boolean; - user: UserDetails; + user: Pick; isAdminReset?: boolean; + /** Signed in through a trusted proxy, which vouches for the user in place of the current password */ + currentPasswordOptional?: boolean; onClose: () => void; onSuccess: () => void; } @@ -55,6 +58,7 @@ export function ChangePasswordDialog({ isOpen, user, isAdminReset, + currentPasswordOptional, onClose, onSuccess, }: ChangePasswordDialogProps) { @@ -70,7 +74,7 @@ export function ChangePasswordDialog({ }); const onSubmit = async (values: ChangePasswordFormValues) => { - if (!isAdminReset && !values.currentPassword) { + if (!isAdminReset && !currentPasswordOptional && !values.currentPassword) { form.setError('currentPassword', { message: 'Current password is required' }); return; } @@ -78,7 +82,8 @@ export function ChangePasswordDialog({ await changePasswordMutation.mutateAsync({ id: user.id, passwordData: { - currentPassword: isAdminReset ? undefined : values.currentPassword, + currentPassword: + isAdminReset || !values.currentPassword ? undefined : values.currentPassword, newPassword: values.newPassword, }, }); @@ -113,10 +118,17 @@ export function ChangePasswordDialog({ name="currentPassword" render={({ field }) => ( - Current Password + + Current Password{currentPasswordOptional ? ' (optional)' : ''} + + {currentPasswordOptional ? ( + + Not needed while you're signed in through single sign-on. + + ) : null} )} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 09b3827..c8ab44b 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -7,6 +7,7 @@ import { apiClient } from '../lib/api/client'; import { LoginCredentials, RegisterData } from '../types/auth'; import { PublicSettings } from '../types/settings'; import { logger } from '../lib/logger'; +import { SKIP_TRUSTED_LOGIN_KEY } from '../lib/trustedLogin'; /** Validate redirect path to prevent open redirects */ function sanitizeRedirectPath(path: string | undefined): string { @@ -64,6 +65,7 @@ export function AuthProvider({ children }: AuthProviderProps) { async (credentials: LoginCredentials, redirectPath?: string) => { try { const result = await authApi.login(credentials); + sessionStorage.removeItem(SKIP_TRUSTED_LOGIN_KEY); // Clear user-specific cached queries, but keep public settings (not user-specific) queryClient.removeQueries({ @@ -114,6 +116,7 @@ export function AuthProvider({ children }: AuthProviderProps) { ); const logout = useCallback(async () => { + sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1'); try { await authApi.logout(); } catch (error) { diff --git a/frontend/src/hooks/useTrustedLogin.test.tsx b/frontend/src/hooks/useTrustedLogin.test.tsx new file mode 100644 index 0000000..6c8f3df --- /dev/null +++ b/frontend/src/hooks/useTrustedLogin.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useTrustedLogin } from './useTrustedLogin'; +import { useAuthStore } from '@/store/auth'; +import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin'; + +const trustedLogin = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/api', () => ({ authApi: { trustedLogin } })); +vi.mock('@/store/theme', () => ({ + useThemeStore: { getState: () => ({ loadThemeFromServer: () => Promise.resolve() }) }, +})); + +const user = { + id: 7, + username: 'brad', + email: 'brad@example.com', + role: 'user', + permissions: ['games.read'], +}; + +function render(path: string) { + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => ({ checked: useTrustedLogin(), location: useLocation() }), { wrapper }); +} + +describe('useTrustedLogin', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + useAuthStore.getState().clearAuth(); + }); + + it('signs in the user the proxy vouches for and leaves the login page', async () => { + trustedLogin.mockResolvedValue({ user, tokens: { expiresIn: 3600 } }); + + const { result } = render('/login'); + expect(result.current.checked).toBe(false); + + await waitFor(() => expect(result.current.checked).toBe(true)); + expect(useAuthStore.getState()).toMatchObject({ + user, + isAuthenticated: true, + isTrustedSession: true, + }); + expect(result.current.location.pathname).toBe('/'); + }); + + it('continues signed out when there is no trusted identity', async () => { + trustedLogin.mockRejectedValue(new Error('403')); + + const { result } = render('/games/abc'); + + await waitFor(() => expect(result.current.checked).toBe(true)); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(result.current.location.pathname).toBe('/games/abc'); + }); + + it('does not ask after an explicit logout or when already signed in', () => { + sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1'); + expect(render('/').result.current.checked).toBe(true); + + sessionStorage.clear(); + useAuthStore.getState().setAuth(user); + expect(render('/').result.current.checked).toBe(true); + + expect(trustedLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useTrustedLogin.ts b/frontend/src/hooks/useTrustedLogin.ts new file mode 100644 index 0000000..247505d --- /dev/null +++ b/frontend/src/hooks/useTrustedLogin.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { authApi } from '@/lib/api'; +import { logger } from '@/lib/logger'; +import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin'; +import { useAuthStore } from '@/store/auth'; + +const AUTH_PAGES = ['/login', '/register', '/setup']; + +/** + * A signed-out visitor may be vouched for by an authenticating reverse proxy. + * Asks once on load, before guest mode or a login redirect takes over, and + * returns whether that check is done. + */ +export function useTrustedLogin(): boolean { + const navigate = useNavigate(); + const location = useLocation(); + const queryClient = useQueryClient(); + const [checked, setChecked] = useState( + () => + useAuthStore.getState().isAuthenticated || + sessionStorage.getItem(SKIP_TRUSTED_LOGIN_KEY) !== null + ); + + useEffect(() => { + if (checked) return; + let cancelled = false; + authApi + .trustedLogin() + .then((result) => { + if (cancelled) return; + useAuthStore.getState().setAuth(result.user, true); + queryClient.invalidateQueries({ queryKey: ['setupStatus'] }); + if (AUTH_PAGES.includes(location.pathname)) { + navigate('/', { replace: true }); + } + }) + .catch(() => { + logger.debug('[useTrustedLogin] No trusted proxy identity; continuing signed out'); + }) + .finally(() => { + if (!cancelled) setChecked(true); + }); + return () => { + cancelled = true; + }; + // Runs once on load; later navigation must not repeat it + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return checked; +} diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 26785fd..d563cd6 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -18,6 +18,12 @@ export const authApi = { return data; }, + /** Sign in as the user an authenticating reverse proxy vouches for, if any */ + trustedLogin: async (): Promise => { + const { data } = await apiClient.post('/auth/trusted-login'); + return data; + }, + logout: async (): Promise<{ success: boolean; message: string }> => { const { data } = await apiClient.post('/auth/logout'); return data; diff --git a/frontend/src/lib/trustedLogin.ts b/frontend/src/lib/trustedLogin.ts new file mode 100644 index 0000000..578c251 --- /dev/null +++ b/frontend/src/lib/trustedLogin.ts @@ -0,0 +1,5 @@ +/** + * Set for the rest of the tab's session after an explicit logout, so the app + * doesn't sign straight back in through the trusted proxy header. + */ +export const SKIP_TRUSTED_LOGIN_KEY = 'flashpoint-skip-trusted-login'; diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index f48208a..1abbc1e 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -9,8 +9,10 @@ interface AuthState { isAuthenticated: boolean; isGuest: boolean; isMaintenanceMode: boolean; + /** Signed in through a trusted proxy header rather than a password */ + isTrustedSession: boolean; - setAuth: (user: User) => void; + setAuth: (user: User, isTrustedSession?: boolean) => void; setGuestMode: () => void; clearAuth: () => void; updateUser: (user: User) => void; @@ -29,12 +31,14 @@ export const useAuthStore = create()( isAuthenticated: false, isGuest: false, isMaintenanceMode: false, + isTrustedSession: false, - setAuth: (user: User) => { + setAuth: (user: User, isTrustedSession = false) => { set({ user, isAuthenticated: true, isGuest: false, + isTrustedSession, }); if (user.role !== 'guest') { @@ -66,6 +70,7 @@ export const useAuthStore = create()( isAuthenticated: false, isGuest: false, isMaintenanceMode: false, + isTrustedSession: false, }); }, @@ -141,6 +146,7 @@ export const useAuthStore = create()( user: state.user, isAuthenticated: state.isAuthenticated, isGuest: state.isGuest, + isTrustedSession: state.isTrustedSession, }), } ) diff --git a/frontend/src/views/SettingsView.tsx b/frontend/src/views/SettingsView.tsx index 725f3ab..3ef7bdc 100644 --- a/frontend/src/views/SettingsView.tsx +++ b/frontend/src/views/SettingsView.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Settings } from 'lucide-react'; import { AnimatePresence } from 'framer-motion'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { AccountPasswordCard } from '@/components/settings/AccountPasswordCard'; import { useAuthStore } from '@/store/auth'; import { useQuery } from '@tanstack/react-query'; import { ruffleApi } from '@/lib/api'; @@ -62,6 +63,8 @@ export function SettingsView() {

Settings

+ + {isAdmin ? (