diff --git a/frontend/src/__tests__/LoginPage.google-signin.test.tsx b/frontend/src/__tests__/LoginPage.google-signin.test.tsx index ac4882b2..e9ee9fbf 100644 --- a/frontend/src/__tests__/LoginPage.google-signin.test.tsx +++ b/frontend/src/__tests__/LoginPage.google-signin.test.tsx @@ -1,34 +1,36 @@ /** * LoginPage.google-signin.test.tsx * - * Component-level unit tests for the Google Sign-In (via Authentik OIDC) flow - * on the LoginPage. + * Component-level unit tests for the social ("Sign in with Google") flow on the + * LoginPage, plus the credentials sign-in path. * - * Architecture reminder: - * User → "Sign in with Authentik" → backend /api/auth/oidc/login - * → Authentik (has Google button) → user completes Google auth - * → Authentik callback → backend /api/auth/oidc/callback - * → backend mints short-lived exchange code → redirect to frontend - * with ?code=<32-byte-hex> - * → frontend handleOIDCCallback() reads ?code= and POSTs to - * /api/auth/token-exchange - * → backend returns { token: JWT, sessionId } - * → frontend stores token, fetches /api/auth/user, navigates to /dashboard + * Architecture reminder (provider-neutral): + * The browser only ever talks to FuzeFront's OWN same-origin Security API + * (/api/v1/security/*). No identity provider is named on the consumer surface — + * the federation/MFA engine behind it is a swappable server-side adapter. * - * What is NOT tested here (already covered by handleOIDCCallback.test.ts): - * - The internals of authAPI.handleOIDCCallback (?code= exchange, error - * param handling, empty URL, ?token= security boundary). + * User → "Sign in with Google" → authAPI.startSocialLogin('google') + * → 302 to the same-host social authorize path → provider consent + * → app is returned to with ?code= + * → authAPI.handleAuthCallback() exchanges the code for a SessionResult + * → completeSession() hydrates the user and lands on /dashboard. + * + * The internals of handleAuthCallback (?code= exchange, error param, empty URL, + * ?token= security boundary) are covered by handleAuthCallback.test.ts. * * What IS tested here: - * 1. OIDC button visibility based on oidcConfigured flag - * 2. Clicking OIDC button calls loginWithOIDC - * 3. Error from handleOIDCCallback is surfaced on the page - * 4. Successful token from handleOIDCCallback triggers getCurrentUser + redirect - * 5. Sign-Up affordance button also calls loginWithOIDC + * 1. Google button visibility driven by the neutral `social` capability list + * 2. Clicking the Google button calls authAPI.startSocialLogin('google') + * 3. Error from handleAuthCallback is surfaced on the page + * 4. A successful callback SessionResult triggers getCurrentUser + redirect + * 5. Credentials submit calls authAPI.login (single, provider-neutral path) + * 6. The sign-up affordance toggles the in-page sign-up form (no external + * enrollment redirect) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' +import type { AuthMethods } from '@fuzefront/security-client' // ── Hoisted mocks (processed before imports) ────────────────────────────── @@ -71,23 +73,25 @@ function makeUserCtx(overrides: Partial { +describe('LoginPage — social / Google Sign-In UI', () => { /** * Mutable location stub — written by the component when it does * `window.location.href = '/dashboard'`. Reset in beforeEach. @@ -119,12 +123,11 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { ;(sharedMock.useCurrentUser as ReturnType).mockReturnValue(makeUserCtx()) // Default authAPI spies — individual tests override per-case. - vi.spyOn(authAPI, 'handleOIDCCallback').mockResolvedValue({}) + vi.spyOn(authAPI, 'handleAuthCallback').mockResolvedValue({}) vi.spyOn(authAPI, 'getAuthMethods') - vi.spyOn(authAPI, 'loginWithOIDC').mockResolvedValue(undefined) - vi.spyOn(authAPI, 'signupWithOIDC').mockResolvedValue(undefined) - vi.spyOn(authAPI, 'loginWithAuthentikPassword') + vi.spyOn(authAPI, 'startSocialLogin').mockResolvedValue(undefined) vi.spyOn(authAPI, 'login') + vi.spyOn(authAPI, 'signup') vi.spyOn(authAPI, 'getCurrentUser') // Suppress api.ts / component console noise so test output stays clean. @@ -139,10 +142,10 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { vi.restoreAllMocks() }) - // ── 1: No Google button, local-auth form fallback when oidcConfigured=false + // ── 1: No Google button, credentials form fallback when no social provider ─ - it('renders the credentials form but no Google button when oidcConfigured is false', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(LOCAL_ONLY_METHODS) + it('renders the credentials form but no Google button when no social provider is advertised', async () => { + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(PASSWORD_ONLY_METHODS) render() @@ -154,13 +157,13 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { expect(screen.getByLabelText(/password/i)).toBeInTheDocument() expect(screen.queryByText(/sign in with authentik/i)).not.toBeInTheDocument() - expect(screen.queryByText(/sign in with google/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /sign in with google/i })).not.toBeInTheDocument() }) // ── 1b: Regression — auth methods fetched ONCE, not in a render loop ───── it('fetches auth methods exactly once (no re-render loop)', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(SOCIAL_METHODS) render() @@ -170,17 +173,17 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { // Give any runaway effect a chance to re-fire before asserting. await new Promise(r => setTimeout(r, 100)) - // A single mount must produce a single /api/auth/method fetch. Before the - // useCurrentUser stabilization, an unstable setUser ref re-fired the - // page-load effect every render and flooded this endpoint (~2-3 req/s). + // A single mount must produce a single capability fetch + one callback probe. + // Before the useCurrentUser stabilization, an unstable setUser ref re-fired + // the page-load effect every render and flooded this endpoint (~2-3 req/s). expect(authAPI.getAuthMethods).toHaveBeenCalledTimes(1) - expect(authAPI.handleOIDCCallback).toHaveBeenCalledTimes(1) + expect(authAPI.handleAuthCallback).toHaveBeenCalledTimes(1) }) - // ── 2: Native credentials form + Google button when oidcConfigured is true + // ── 2: Credentials form AND Google button when social is advertised ─────── - it('renders the credentials form AND "Sign in with Google" (no Authentik redirect button) when oidcConfigured is true', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + it('renders the credentials form AND "Sign in with Google" when the social provider is advertised', async () => { + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(SOCIAL_METHODS) render() @@ -189,16 +192,16 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { screen.getByRole('button', { name: /sign in with google/i }) ).toBeInTheDocument() }) - // Default UI components for credentials — always present. + // Default UI components for credentials — always present when password is enabled. expect(screen.getByLabelText(/email/i)).toBeInTheDocument() expect(screen.getByLabelText(/password/i)).toBeInTheDocument() - // The redirect button is gone — Authentik is driven server-side instead. + // No provider is named on the consumer surface. expect(screen.queryByText(/sign in with authentik/i)).not.toBeInTheDocument() }) - // ── 2a: Form submit verifies credentials AGAINST AUTHENTIK when configured + // ── 2a: Credentials submit uses the single provider-neutral login path ──── - it('submitting the form calls loginWithAuthentikPassword (not local login) when oidcConfigured is true', async () => { + it('submitting the form calls the provider-neutral authAPI.login and lands on /dashboard', async () => { const mockUser = { id: 'user-1', email: 'someone@example.com', @@ -210,12 +213,16 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { ;(sharedMock.useCurrentUser as ReturnType).mockReturnValue( makeUserCtx({ setUser }) ) - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) - vi.mocked(authAPI.loginWithAuthentikPassword).mockResolvedValue({ - token: 'jwt-authentik', - sessionId: 'sess-ak', + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(SOCIAL_METHODS) + // Password login returns an authenticated SessionResult; completeSession then + // hydrates the user via getCurrentUser. + vi.mocked(authAPI.login).mockResolvedValue({ + status: 'authenticated', + token: 'jwt-1', + sessionId: 'sess-1', user: mockUser, - } as any) + }) + vi.mocked(authAPI.getCurrentUser).mockResolvedValue(mockUser as any) render() @@ -233,47 +240,20 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { fireEvent.click(screen.getByRole('button', { name: /^sign in$/i })) }) - expect(authAPI.loginWithAuthentikPassword).toHaveBeenCalledWith({ + expect(authAPI.login).toHaveBeenCalledWith({ email: 'someone@example.com', password: 'hunter22', }) - expect(authAPI.login).not.toHaveBeenCalled() - expect(setUser).toHaveBeenCalledWith(mockUser) - expect(locationStub.href).toBe('/dashboard') - }) - - it('submitting the form calls the LOCAL login when oidcConfigured is false', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(LOCAL_ONLY_METHODS) - vi.mocked(authAPI.login).mockResolvedValue({ - token: 'jwt-local', - sessionId: 'sess-local', - user: { id: 'u2', email: 'dev@local', firstName: 'D', lastName: 'V', roles: ['user'] }, - } as any) - - render() - await waitFor(() => { - expect(screen.getByLabelText(/email/i)).toBeInTheDocument() - }) - - fireEvent.change(screen.getByLabelText(/email/i), { - target: { value: 'dev@local' }, - }) - fireEvent.change(screen.getByLabelText(/password/i), { - target: { value: 'pw' }, - }) - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^sign in$/i })) + expect(setUser).toHaveBeenCalledWith(mockUser) }) - - expect(authAPI.login).toHaveBeenCalledTimes(1) - expect(authAPI.loginWithAuthentikPassword).not.toHaveBeenCalled() + expect(locationStub.href).toBe('/dashboard') }) - // ── 2b: Clicking the Google button starts the Authentik OIDC redirect ──── + // ── 2b: Clicking the Google button starts the server-brokered social flow ─ - it('clicking "Sign in with Google" calls authAPI.loginWithOIDC (Google is federated via Authentik)', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + it('clicking "Sign in with Google" calls authAPI.startSocialLogin("google")', async () => { + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(SOCIAL_METHODS) render() @@ -287,19 +267,19 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { fireEvent.click(screen.getByRole('button', { name: /sign in with google/i })) }) - expect(authAPI.loginWithOIDC).toHaveBeenCalledTimes(1) + expect(authAPI.startSocialLogin).toHaveBeenCalledWith('google') }) - // ── 4: Error from handleOIDCCallback surfaces on the page ─────────────── + // ── 4: Error from handleAuthCallback surfaces on the page ─────────────── - it('shows "Authentication Error" on page when handleOIDCCallback returns an error', async () => { - // Simulates landing on the login page after an OIDC provider error - // (?error=oidc_error&message=access_denied in the URL). - // handleOIDCCallback reads those params and returns { error: '...' }. - vi.mocked(authAPI.handleOIDCCallback).mockResolvedValue({ + it('shows "Authentication Error" on page when handleAuthCallback returns an error', async () => { + // Simulates landing on the login page after a social-provider error + // (?error=...&message=access_denied in the URL). handleAuthCallback reads + // those params and returns { error: '...' }. + vi.mocked(authAPI.handleAuthCallback).mockResolvedValue({ error: 'access_denied', }) - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(LOCAL_ONLY_METHODS) + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(PASSWORD_ONLY_METHODS) render() @@ -311,9 +291,9 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { expect(screen.getByText(/access_denied/i)).toBeInTheDocument() }) - // ── 5: Successful callback token → getCurrentUser → navigate to /dashboard + // ── 5: Successful callback SessionResult → getCurrentUser → /dashboard ──── - it('completes login and navigates to /dashboard when handleOIDCCallback returns a token', async () => { + it('completes login and navigates to /dashboard when handleAuthCallback returns an authenticated session', async () => { const mockUser = { id: 'user-1', email: 'test@google.com', @@ -327,17 +307,22 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { makeUserCtx({ setUser }) ) - // handleOIDCCallback returns a token (from a ?code= exchange that already - // happened internally — the real function is tested in handleOIDCCallback.test.ts). - vi.mocked(authAPI.handleOIDCCallback).mockResolvedValue({ - token: 'jwt-test-token', - sessionId: 'sess-1', + // handleAuthCallback returns an authenticated SessionResult (from a ?code= + // exchange that already happened internally — the real function is tested in + // handleAuthCallback.test.ts). + vi.mocked(authAPI.handleAuthCallback).mockResolvedValue({ + result: { + status: 'authenticated', + token: 'jwt-test-token', + sessionId: 'sess-1', + user: mockUser, + }, }) - vi.mocked(authAPI.getCurrentUser).mockResolvedValue(mockUser) + vi.mocked(authAPI.getCurrentUser).mockResolvedValue(mockUser as any) render() - // Component calls getCurrentUser after receiving the token. + // Component calls getCurrentUser after receiving the authenticated session. await waitFor(() => { expect(authAPI.getCurrentUser).toHaveBeenCalledTimes(1) }) @@ -351,12 +336,10 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { expect(locationStub.href).toBe('/dashboard') }) - // ── 6: Sign-Up affordance goes to the ENROLLMENT flow, not plain login ─── + // ── 6: Sign-Up affordance toggles the in-page enrollment form ──────────── - it('Sign-Up button calls signupWithOIDC (Authentik enrollment path), not loginWithOIDC', async () => { - // The sign-up button is always rendered (not conditional on oidcConfigured). - // It routes new users through Authentik ENROLLMENT (/api/auth/oidc/signup). - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + it('the sign-up button toggles the in-page sign-up form (no external enrollment redirect)', async () => { + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(SOCIAL_METHODS) render() @@ -376,17 +359,22 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { fireEvent.click(signUpButton) }) - expect(authAPI.signupWithOIDC).toHaveBeenCalledTimes(1) - expect(authAPI.loginWithOIDC).not.toHaveBeenCalled() + // Toggling to sign-up mode reveals the enrollment fields and relabels the + // primary action — all on the same page, brokered by the Security API. + await waitFor(() => { + expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument() + }) + expect(screen.getByLabelText(/first name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/last name/i)).toBeInTheDocument() }) // ── 7: Per-action pending labels — only the clicked button shows progress ─ it('clicking Google shows "Redirecting" ONLY on the Google button; the others just disable', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(SOCIAL_METHODS) // Keep the redirect "in flight" — location.href assignment doesn't unload // jsdom, so the component stays mounted with pending === 'google'. - vi.mocked(authAPI.loginWithOIDC).mockImplementation(() => new Promise(() => {})) + vi.mocked(authAPI.startSocialLogin).mockImplementation(() => new Promise(() => {})) render() diff --git a/frontend/src/__tests__/handleOIDCCallback.test.ts b/frontend/src/__tests__/handleAuthCallback.test.ts similarity index 54% rename from frontend/src/__tests__/handleOIDCCallback.test.ts rename to frontend/src/__tests__/handleAuthCallback.test.ts index 850ef5f4..c8c52f21 100644 --- a/frontend/src/__tests__/handleOIDCCallback.test.ts +++ b/frontend/src/__tests__/handleAuthCallback.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -// authAPI.handleOIDCCallback calls `api.post` where `api` is the module-level +// authAPI.handleAuthCallback calls `api.post` where `api` is the module-level // axios instance — the same object reference as the default export of ../services/api. // We use vi.spyOn on the imported default to intercept calls made by the // closed-over internal `api` variable (same object reference). @@ -13,6 +13,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import api, { authAPI } from '../services/api' +// The provider-neutral Security API surface the browser talks to. No identity +// provider is named — the social callback exchanges an opaque `?code=` for a +// session at the same-origin `/api/v1/security/session/exchange` endpoint. +const EXCHANGE_PATH = '/v1/security/session/exchange' + // Mock localStorage const localStorageMock = (() => { let store: Record = {} @@ -37,7 +42,7 @@ function setSearch(search: string) { }) } -describe('authAPI.handleOIDCCallback', () => { +describe('authAPI.handleAuthCallback', () => { beforeEach(() => { localStorageMock.clear() vi.clearAllMocks() @@ -51,25 +56,49 @@ describe('authAPI.handleOIDCCallback', () => { vi.restoreAllMocks() }) - it('exchanges code for token and stores in localStorage', async () => { + it('exchanges code for an authenticated session and stores it in localStorage', async () => { setSearch('?code=abc123') vi.mocked(api.post).mockResolvedValueOnce({ - data: { token: 'jwt-token', sessionId: 'sess-1' } + data: { + status: 'authenticated', + token: 'jwt-token', + sessionId: 'sess-1', + user: { id: 'u1' }, + }, }) - const result = await authAPI.handleOIDCCallback() + const result = await authAPI.handleAuthCallback() - expect(api.post).toHaveBeenCalledWith('/auth/token-exchange', { code: 'abc123' }) + expect(api.post).toHaveBeenCalledWith(EXCHANGE_PATH, { code: 'abc123' }) expect(localStorageMock.getItem('authToken')).toBe('jwt-token') expect(localStorageMock.getItem('sessionId')).toBe('sess-1') - expect(result).toEqual({ token: 'jwt-token', sessionId: 'sess-1' }) + expect(result.result).toMatchObject({ status: 'authenticated', token: 'jwt-token' }) + expect(result.error).toBeUndefined() expect(mockReplaceState).toHaveBeenCalled() }) + it('returns an mfa_required challenge without persisting a session', async () => { + setSearch('?code=needs-mfa') + vi.mocked(api.post).mockResolvedValueOnce({ + data: { + status: 'mfa_required', + challengeId: 'ch-1', + factors: [{ factorId: 'f1', type: 'totp' }], + }, + }) + + const result = await authAPI.handleAuthCallback() + + expect(api.post).toHaveBeenCalledWith(EXCHANGE_PATH, { code: 'needs-mfa' }) + expect(result.result).toMatchObject({ status: 'mfa_required', challengeId: 'ch-1' }) + // No session is established for an MFA challenge. + expect(localStorageMock.getItem('authToken')).toBeNull() + }) + it('returns error from URL when error param present', async () => { - setSearch('?error=oidc_error&message=access_denied') + setSearch('?error=social_error&message=access_denied') - const result = await authAPI.handleOIDCCallback() + const result = await authAPI.handleAuthCallback() expect(result).toEqual({ error: 'access_denied' }) expect(api.post).not.toHaveBeenCalled() @@ -78,7 +107,7 @@ describe('authAPI.handleOIDCCallback', () => { it('returns empty object when no params in URL', async () => { setSearch('') - const result = await authAPI.handleOIDCCallback() + const result = await authAPI.handleAuthCallback() expect(result).toEqual({}) expect(api.post).not.toHaveBeenCalled() @@ -87,10 +116,23 @@ describe('authAPI.handleOIDCCallback', () => { it('does not read token directly from URL', async () => { setSearch('?token=some-jwt&sessionId=sess') - const result = await authAPI.handleOIDCCallback() + const result = await authAPI.handleAuthCallback() expect(result).toEqual({}) expect(api.post).not.toHaveBeenCalled() expect(localStorageMock.getItem('authToken')).toBeNull() }) + + it('surfaces a friendly error when the exchange POST fails', async () => { + setSearch('?code=will-fail') + vi.mocked(api.post).mockRejectedValueOnce({ + response: { data: { error: 'exchange_failed' } }, + }) + + const result = await authAPI.handleAuthCallback() + + expect(result.error).toBe('exchange_failed') + expect(result.result).toBeUndefined() + expect(localStorageMock.getItem('authToken')).toBeNull() + }) }) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 448e0064..a0d8276a 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,7 +1,9 @@ import React, { useState, useEffect, useRef } from 'react' import { useLanguage } from '../contexts/LanguageContext' import { useCurrentUser } from '../lib/shared' -import { authAPI, AuthMethods } from '../services/api' +import { authAPI } from '../services/api' +import type { AuthMethods, SessionResult } from '../services/api' +import { Button, Input, Alert, SeamDivider } from '@fuzefront/design-system' import FrontFuseLogo from '../assets/FrontFuseLogo.png' // Official Google "G" mark palette — these exact values are mandated by @@ -14,167 +16,141 @@ const GOOGLE_BRAND = { green: '#34A853', // ds-conformance-allow: third-party brand mark (Google identity guidelines) } +// Neutral fallback capability descriptor — password-only. Used when the Security +// API can't be reached so the form is still usable. No provider is named: the +// browser only ever knows FuzeFront's own /api/v1/security surface. +const FALLBACK_METHODS: AuthMethods = { + password: true, + social: [], + mfa: { enabled: false, types: [] }, + verification: { email: false, sms: false }, +} + /** Which sign-in action is in flight. Per-action (not a single boolean) so the * button the user clicked shows ITS progress label while the others merely * disable — a shared flag made every button flip to "Redirecting…" at once. */ type PendingAction = 'credentials' | 'google' | 'signup' | null +type FormMode = 'signin' | 'signup' + function LoginPage() { const { t } = useLanguage() + const [mode, setMode] = useState('signin') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') const [pending, setPending] = useState(null) const loading = pending !== null const [error, setError] = useState('') + const [notice, setNotice] = useState('') const [authMethods, setAuthMethods] = useState(null) - const [diagnostics, setDiagnostics] = useState(null) - const [showDiagnostics, setShowDiagnostics] = useState(false) const { setUser } = useCurrentUser() - // Handle OIDC callback on page load + // Route an authenticated Security-API session into the app: hydrate the + // current user then land on the dashboard. A `SessionResult` may instead be an + // `mfa_required` challenge (step-up) — surfaced as a notice here; the full + // step-up UI is a separate, flagged screen. + const completeSession = async (result: SessionResult): Promise => { + if (result.status === 'mfa_required') { + setNotice( + 'Additional verification is required to finish signing in. Please complete the verification step to continue.' + ) + return + } + try { + const user = await authAPI.getCurrentUser() + setUser(user) + window.location.href = '/dashboard' + } catch (err) { + console.error('Failed to hydrate user after sign-in:', err) + setError('Signed in, but failed to load your profile. Please retry.') + } + } + + // Handle a social sign-in round-trip on page load. The provider callback + // returns to the app with an opaque `?code=`; exchange it for a session. useEffect(() => { - authAPI.handleOIDCCallback().then(oidcResult => { - if (oidcResult.error) { - setError(`Authentication failed: ${oidcResult.error}`) - // Leave the login form usable so the user can retry + authAPI + .handleAuthCallback() + .then(({ result, error: callbackError }) => { + if (callbackError) { + setError(`Authentication failed: ${callbackError}`) + loadAuthMethods() + return + } + if (result) { + void completeSession(result) + return + } loadAuthMethods() - return - } - - if (oidcResult.token) { - console.log('🎉 OIDC authentication successful') - // Get user info and redirect - authAPI.getCurrentUser() - .then(user => { - setUser(user) - window.location.href = '/dashboard' - }) - .catch(err => { - console.error('❌ Failed to get user after OIDC login:', err) - setError('Failed to get user information') - }) - return - } - - // Load authentication methods - loadAuthMethods() - }).catch(err => { - // Backstop: a rejected promise must never freeze the page - console.error('❌ Unexpected error in OIDC callback handler:', err) - setError('Authentication encountered an unexpected error. Please try again.') - loadAuthMethods() - }) - // Runs ONCE on mount — this is a page-load handler (OIDC-callback exchange - // + auth-method fetch). Depending on setUser here previously re-fired the - // effect on every render and flooded /api/auth/method; setUser is now a - // stable ref, but a mount-only effect is the correct shape regardless. + }) + .catch(err => { + // Backstop: a rejected promise must never freeze the page. + console.error('Unexpected error in auth-callback handler:', err) + setError('Authentication encountered an unexpected error. Please try again.') + loadAuthMethods() + }) + // Runs ONCE on mount — this is a page-load handler (social-callback exchange + // + auth-method fetch). setUser is a stable ref; a mount-only effect is the + // correct shape for a page-load handler regardless. }, []) const loadAuthMethods = async () => { try { const methods = await authAPI.getAuthMethods() setAuthMethods(methods) - console.log('🔧 Available authentication methods:', methods) - } catch (error) { - console.error('❌ Failed to load auth methods:', error) - // Fallback to local auth only - setAuthMethods({ - methods: ['local'], - oidcConfigured: false, - defaultMethod: 'local' - }) + } catch (err) { + console.error('Failed to load auth methods:', err) + setAuthMethods(FALLBACK_METHODS) } } - // Log environment information on component mount (dev-only: this block also - // fired a /health probe and dumped env/localStorage details into the prod - // console on every login-page visit). - useEffect(() => { - if (!import.meta.env.DEV) return - console.log('🏠 LoginPage mounted - Environment Info:', { - timestamp: new Date().toISOString(), - currentURL: window.location.href, - origin: window.location.origin, - hostname: window.location.hostname, - port: window.location.port, - protocol: window.location.protocol, - userAgent: navigator.userAgent, - cookieEnabled: navigator.cookieEnabled, - onLine: navigator.onLine, - language: navigator.language, - platform: navigator.platform, - localStorage: { - available: typeof Storage !== 'undefined', - authToken: !!localStorage.getItem('authToken'), - tokenPreview: - localStorage.getItem('authToken')?.substring(0, 20) + '...' || 'none', - }, - env: { - NODE_ENV: import.meta.env.NODE_ENV, - MODE: import.meta.env.MODE, - VITE_API_URL: import.meta.env.VITE_API_URL, - BASE_URL: import.meta.env.BASE_URL, - DEV: import.meta.env.DEV, - PROD: import.meta.env.PROD, - }, - }) - - // Test network connectivity - console.log('🌐 Testing network connectivity...') - fetch('/health') - .then(response => { - console.log('✅ Health endpoint accessible:', { - status: response.status, - statusText: response.statusText, - url: response.url, - }) - }) - .catch(error => { - console.log('❌ Health endpoint not accessible:', { - error: error.message, - type: error.constructor.name, - }) - }) - }, []) - - // Credentials form submit. When Authentik/OIDC is configured the credentials - // are verified AGAINST AUTHENTIK (server-side flow-executor — no redirect); - // the local users-table login is only the fallback for stacks without - // Authentik (local dev, CI ephemeral environments). - const handleCredentialsLogin = async (e: React.FormEvent) => { + // Credentials submit — password sign-in OR account creation, both brokered by + // FuzeFront's own Security API. The user only ever sees FuzeFront-branded UI; + // the identity engine behind it is a swappable server-side adapter. + const handleCredentialsSubmit = async (e: React.FormEvent) => { e.preventDefault() setPending('credentials') setError('') + setNotice('') try { - const { token, user } = authMethods?.oidcConfigured - ? await authAPI.loginWithAuthentikPassword({ email, password }) - : await authAPI.login({ email, password }) - - if (token && user) { - setUser(user) - window.location.href = '/dashboard' - } else { - throw new Error('Invalid response from server') + if (mode === 'signup') { + const { token, user } = await authAPI.signup({ + email, + password, + firstName: firstName || undefined, + lastName: lastName || undefined, + }) + if (token && user) { + setUser(user) + window.location.href = '/dashboard' + } else { + throw new Error('Invalid response from server') + } + return } - } catch (err: any) { - console.error('❌ Login error:', err) - let errorMessage = err.response?.data?.error || err.message || 'Login failed' + const result = await authAPI.login({ email, password }) + await completeSession(result) + } catch (err: any) { + console.error('Authentication error:', err) + let errorMessage = + err.response?.data?.error || err.message || 'Authentication failed' if (err.code === 'NETWORK_ERROR' || !err.response) { - errorMessage += ' (Network connection failed - check if backend is running)' + errorMessage += ' (Network connection failed — check if the service is running)' } else if (err.response?.status === 500) { - errorMessage += ' (Server error - check backend logs)' + errorMessage += ' (Server error — please try again shortly)' } - setError(errorMessage) } finally { setPending(null) } } - // The redirect actions leave the page, so `pending` normally never resets. - // If the navigation target hangs (auth service not answering), the page + // The social redirect leaves the page, so `pending` normally never resets. If + // the navigation target hangs (the sign-in service isn't answering), the page // would sit on "Redirecting…" forever — recover after a grace period. const redirectWatchdog = useRef | null>(null) useEffect(() => { @@ -183,118 +159,35 @@ function LoginPage() { } }, []) - const startRedirect = ( - action: 'google' | 'signup', - navigate: () => void | Promise - ) => { - setPending(action) + const handleGoogleLogin = () => { + setPending('google') setError('') + setNotice('') Promise.resolve() - .then(navigate) + .then(() => authAPI.startSocialLogin('google')) .then(() => { if (redirectWatchdog.current) clearTimeout(redirectWatchdog.current) redirectWatchdog.current = setTimeout(() => { setPending(null) - setError( - 'The sign-in service is not responding. Please try again in a moment.' - ) + setError('The sign-in service is not responding. Please try again in a moment.') }, 12000) }) .catch((err: any) => { - console.error('❌ OIDC redirect error:', err) + console.error('Social sign-in redirect error:', err) setError('Failed to start sign-in') setPending(null) }) } - const handleGoogleLogin = () => - startRedirect('google', () => authAPI.loginWithOIDC()) - - const handleSignUp = () => - startRedirect('signup', () => authAPI.signupWithOIDC()) - - const runNetworkDiagnostics = async () => { - console.log('🔍 Running network diagnostics...') - const results: any = { - timestamp: new Date().toISOString(), - browser: { - userAgent: navigator.userAgent, - onLine: navigator.onLine, - connection: (navigator as any).connection, - cookieEnabled: navigator.cookieEnabled, - }, - location: { - href: window.location.href, - origin: window.location.origin, - hostname: window.location.hostname, - port: window.location.port, - protocol: window.location.protocol, - }, - environment: { - NODE_ENV: import.meta.env.NODE_ENV, - MODE: import.meta.env.MODE, - VITE_API_URL: import.meta.env.VITE_API_URL, - BASE_URL: import.meta.env.BASE_URL, - }, - tests: {}, - } - - // Test 1: Frontend health endpoint - try { - const response = await fetch('/health') - results.tests.frontendHealth = { - success: true, - status: response.status, - statusText: response.statusText, - url: response.url, - } - } catch (error: any) { - results.tests.frontendHealth = { - success: false, - error: error.message, - type: error.constructor.name, - } - } - - // Test 2: Backend health endpoint - try { - const response = await fetch('/api/health') - results.tests.backendHealth = { - success: true, - status: response.status, - statusText: response.statusText, - url: response.url, - } - } catch (error: any) { - results.tests.backendHealth = { - success: false, - error: error.message, - type: error.constructor.name, - } - } - - // Test 3: Backend auth endpoint (should return 401) - try { - const response = await fetch('/api/auth/user') - results.tests.authEndpoint = { - success: true, - status: response.status, - statusText: response.statusText, - expected401: response.status === 401, - } - } catch (error: any) { - results.tests.authEndpoint = { - success: false, - error: error.message, - type: error.constructor.name, - } - } - - console.log('🔍 Network diagnostics results:', results) - setDiagnostics(results) - setShowDiagnostics(true) + const toggleMode = () => { + setMode(m => (m === 'signin' ? 'signup' : 'signin')) + setError('') + setNotice('') } + const socialEnabled = Boolean(authMethods?.social?.includes('google')) + const passwordEnabled = authMethods?.password !== false + return (
FrontFuse

Welcome to FrontFuse

-

Sign in to access your microfrontend platform

+

+ {mode === 'signin' + ? 'Sign in to access your microfrontend platform' + : 'Create your account to get started'} +

{error && ( -
- Authentication Error: -
+ {error} -
+ + )} + {notice && ( + + {notice} + )} - {/* Credentials form — the DEFAULT sign-in UI. When Authentik/OIDC is - configured, submitting verifies the credentials against AUTHENTIK - server-side (no redirect); Authentik stays the sole identity - authority. Without Authentik (local dev / CI stacks) the same form - falls back to the local users-table login. */} - {authMethods && ( -
-
- - setEmail(e.target.value)} - required - /> -
- -
- - setPassword(e.target.value)} - required - /> -
- - + {/* Credentials form — the DEFAULT sign-in/sign-up UI, brokered entirely by + FuzeFront's own Security API (same-origin /api/v1/security). No identity + provider is named or contacted by the browser. */} + {authMethods && passwordEnabled && ( + + {mode === 'signup' && ( +
+ ) => setFirstName(e.target.value)} + autoComplete="given-name" + style={{ flex: 1 }} + /> + ) => setLastName(e.target.value)} + autoComplete="family-name" + style={{ flex: 1 }} + /> +
+ )} + + ) => setEmail(e.target.value)} + autoComplete="email" + required + /> + + ) => setPassword(e.target.value)} + autoComplete={mode === 'signup' ? 'new-password' : 'current-password'} + required + /> + +
)} - {/* Google sign-in — federated through Authentik (the platform never - contacts Google directly), so the button starts the Authentik OIDC - redirect flow where Google is offered as the identity provider. */} - {authMethods?.oidcConfigured && ( -
+ {/* Social sign-in — brokered through FuzeFront's Security API; the platform + starts a same-host authorize flow and the browser never talks to any + provider directly. Shown only when the capability descriptor advertises + the provider. */} + {socialEnabled && ( +
-
- or -
+ + or +
)} -
- -
- - {showDiagnostics && diagnostics && ( -
-

Network Diagnostics Results

-
{JSON.stringify(diagnostics, null, 2)}
-
- )} - - {/* Sign-up affordance — redirects into Authentik's ENROLLMENT flow, which - chains back into the OIDC authorize step so the new user lands in the - app already signed in (see /api/auth/oidc/signup). */} + {/* Mode toggle — sign-up / sign-in both happen on this page, brokered by + the Security API (no external enrollment redirect). */}
-

- {t('signUpMessage')} +

+ {mode === 'signin' ? t('signUpMessage') : 'Already have an account?'}

- +
) } export default LoginPage - - diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7eb582ab..1d3d4fe6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,14 @@ import axios from 'axios' import { App } from '../lib/shared' +import type { + AuthMethods, + SessionResult, + SocialProvider, +} from '@fuzefront/security-client' + +// Re-export the provider-neutral contract types so existing consumers can keep +// importing them from this module (e.g. `import { AuthMethods } from '../services/api'`). +export type { AuthMethods, SessionResult, SocialProvider } // Default to the same origin the app is served from (the in-pod / ingress nginx // proxies /api/ to the backend). Same-origin keeps the protocol correct — http @@ -189,82 +198,102 @@ export interface LoginResponse { sessionId: string } -export interface AuthMethods { - methods: string[] - oidcConfigured: boolean - defaultMethod: string - oidcLoginUrl?: string +// Account-creation payload for the server-brokered signup endpoint. Mirrors the +// contract `SignupRequest` (see @fuzefront/security-client / openapi.yaml). +export interface SignupCredentials { + email: string + password: string + firstName?: string + lastName?: string + tenantName?: string +} + +// Persist a freshly authenticated session so it survives the post-login page +// reload and is attached to subsequent requests by the axios interceptor. +function persistSession(token?: string, sessionId?: string): void { + if (token) localStorage.setItem('authToken', token) + if (sessionId) localStorage.setItem('sessionId', sessionId) } -// Authentication API +// ───────────────────────────────────────────────────────────────────────────── +// Authentication API — provider-agnostic FuzeFront Security API. +// +// Talks ONLY to FuzeFront's own same-origin `/api/v1/security/*` surface and the +// generated `@fuzefront/security-client` contract types. No identity provider is +// named here or anywhere on the consumer surface: the underlying federation / +// MFA / enrollment engine is a swappable server-side adapter the browser never +// sees. Social login transits only `app.fuzefront.com` and the chosen social +// provider's own consent host. +// ───────────────────────────────────────────────────────────────────────────── +const SECURITY_BASE = '/v1/security' + export const authAPI = { - // Get available authentication methods + // Neutral auth capability descriptor — lets the UI render the right affordances + // (password form, social buttons, MFA, contact verification) without knowing + // any provider. Replaces the legacy vendor-specific `oidcConfigured` boolean. async getAuthMethods(): Promise { - const response = await api.get('/auth/method') + const response = await api.get(`${SECURITY_BASE}/methods`) return response.data }, - // Local authentication - async login(credentials: LoginCredentials): Promise { - const response = await api.post('/auth/login', credentials) - // Persist the token so it survives the post-login page reload and is sent - // on subsequent requests by the axios interceptor. - if (response.data?.token) { - localStorage.setItem('authToken', response.data.token) - } - if (response.data?.sessionId) { - localStorage.setItem('sessionId', response.data.sessionId) + // Password login — establishes a session. Returns a `SessionResult`: either an + // authenticated session, OR (when the account has MFA enabled) an + // `mfa_required` challenge the caller completes via `mfaAPI`. On the + // authenticated branch the session is persisted here. + async login(credentials: LoginCredentials): Promise { + const response = await api.post( + `${SECURITY_BASE}/session`, + credentials + ) + const result = response.data + if (result.status === 'authenticated') { + persistSession(result.token, result.sessionId) } - return response.data - }, - - // OIDC authentication (redirects to Authentik — used for Google/SSO) - async loginWithOIDC(): Promise { - // This will redirect the browser to Authentik - window.location.href = `${API_URL}/auth/oidc/login` - }, - - // Sign-up: redirects to Authentik's ENROLLMENT flow. The backend wraps the - // OIDC authorize URL in the flow's ?next= so a freshly-enrolled user comes - // straight back through the normal OIDC callback, already signed in. - async signupWithOIDC(): Promise { - window.location.href = `${API_URL}/auth/oidc/signup` + return result }, - // Password sign-in against Authentik WITHOUT a redirect: the backend drives - // Authentik's flow-executor with these credentials and returns the same - // { token, user, sessionId } shape as local login. - async loginWithAuthentikPassword( - credentials: LoginCredentials - ): Promise { + // Server-brokered account creation. The user only ever sees FuzeFront-branded + // UI — never a provider's raw enrollment page. On success a session is + // established directly and persisted. + async signup(credentials: SignupCredentials): Promise { const response = await api.post( - '/auth/oidc/password', + `${SECURITY_BASE}/signup`, credentials ) - if (response.data?.token) { - localStorage.setItem('authToken', response.data.token) - } - if (response.data?.sessionId) { - localStorage.setItem('sessionId', response.data.sessionId) - } + persistSession(response.data?.token, response.data?.sessionId) return response.data }, - // Get current user (the backend wraps the payload as { user }) + // Begin a server-brokered social login. 302-redirects the browser to the + // social provider's own consent host via a FuzeFront-owned, same-host authorize + // path; on completion the app is returned to with `?code=` for exchange. + async startSocialLogin(provider: SocialProvider = 'google'): Promise { + window.location.href = `${API_URL}${SECURITY_BASE}/social/${provider}/start` + }, + + // Current identity ("me"). The Security API returns `{ identity, user }`; the + // UI consumes the hydrated user. async getCurrentUser(): Promise { - const response = await api.get<{ user: User }>('/auth/user') + const response = await api.get<{ user: User }>(`${SECURITY_BASE}/session`) return response.data.user }, - // Logout + // Logout — revoke the current session (idempotent) and clear local state. async logout(): Promise { - await api.post('/auth/logout') + await api.delete(`${SECURITY_BASE}/session`) localStorage.removeItem('authToken') + localStorage.removeItem('sessionId') localStorage.removeItem('user') }, - // Handle OIDC callback (exchange ?code for token+sessionId) - async handleOIDCCallback(): Promise<{ token?: string; sessionId?: string; error?: string }> { + // Complete a social-login round-trip. The social callback redirects back to + // the app with a single-use opaque `?code=`; exchange it for a session via + // `POST /session/exchange`. The `SessionResult` may itself be an + // `mfa_required` challenge when the account requires step-up. + async handleAuthCallback(): Promise<{ + result?: SessionResult + error?: string + }> { const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') const error = urlParams.get('error') @@ -276,15 +305,21 @@ export const authAPI = { if (code) { try { - const response = await api.post<{ token: string; sessionId: string }>('/auth/token-exchange', { code }) - const { token, sessionId } = response.data - localStorage.setItem('authToken', token) - localStorage.setItem('sessionId', sessionId) + const response = await api.post( + `${SECURITY_BASE}/session/exchange`, + { code } + ) + const result = response.data + if (result.status === 'authenticated') { + persistSession(result.token, result.sessionId) + } + // Strip the opaque code from the URL so a reload can't re-exchange it. window.history.replaceState({}, document.title, window.location.pathname) - return { token, sessionId } + return { result } } catch (err: any) { - const message = err?.response?.data?.error || err?.message || 'Token exchange failed' - return { error: message } + const failMessage = + err?.response?.data?.error || err?.message || 'Sign-in failed' + return { error: failMessage } } } diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 416659e1..60e237b4 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -3,6 +3,9 @@ interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_WS_URL: string + // Node-style env exposed by some tooling; not a standard Vite key, so it must + // be declared explicitly (DEV/PROD/MODE/BASE_URL come from `vite/client`). + readonly NODE_ENV?: string // more env variables... } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e9edadab..2171650b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -25,6 +25,7 @@ "@fuzefront/billing-ui": ["../packages/billing-ui/src/index.ts"], "@fuzefront/billing-client": ["../billing-client/src/index.ts"], "@fuzefront/app-registry-client": ["../apps-client/src/index.ts"], + "@fuzefront/security-client": ["../packages/security/src/index.ts"], "@fuzefront/design-system": ["../design-system/index.d.ts"] } }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 22fc8301..2d233306 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -49,6 +49,13 @@ const billingClientSrc = fileURLToPath( const appRegistryClientSrc = fileURLToPath( new URL('../apps-client/src/index.ts', import.meta.url) ) +// @fuzefront/security-client (packages/security/) is the generated, provider- +// agnostic Security API client + contract types. Its dist/ is not built in CI — +// resolve from SOURCE, same as the other unpublished workspace packages. The +// frontend consumes only its TYPES (import type), so this alias is a safety net. +const securityClientSrc = fileURLToPath( + new URL('../packages/security/src/index.ts', import.meta.url) +) // Workspace packages resolved from SOURCE (via alias) live outside the frontend/ // directory tree. Rollup walks UP from each file to find node_modules, so it never // reaches frontend/node_modules for those files. This resolver fills the gap: it @@ -81,6 +88,7 @@ export default defineConfig({ '@fuzefront/billing-ui': billingUiSrc, '@fuzefront/billing-client': billingClientSrc, '@fuzefront/app-registry-client': appRegistryClientSrc, + '@fuzefront/security-client': securityClientSrc, // Subpath imports (e.g. styles.css, tokens/*) must map to the design-system // DIRECTORY and precede the exact alias, else `@fuzefront/design-system/styles.css` // resolves under the index.js FILE → ENOTDIR. main.tsx imports the stylesheet.