diff --git a/__tests__/client/ssr-provider.test.tsx b/__tests__/client/ssr-provider.test.tsx new file mode 100644 index 0000000..b1abf30 --- /dev/null +++ b/__tests__/client/ssr-provider.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { Auth0Provider } from '../../src/client/Auth0Provider.js'; +import { useAuth0 } from '../../src/client/use-auth0.js'; +import { createMockUser } from '../../src/testing/index.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockRouteLoaderData = vi.fn(); + +vi.mock('react-router', async importActual => { + const actual = await importActual(); + return { + ...actual, + useNavigate: () => vi.fn(), + useRouteLoaderData: () => mockRouteLoaderData() + }; +}); + +vi.mock('@auth0/auth0-spa-js', () => ({ Auth0Client: vi.fn() })); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const MOCK_USER = createMockUser(); + +function renderSsrProvider( + session: { user: ReturnType } | null = { + user: MOCK_USER + } +) { + mockRouteLoaderData.mockReturnValue({ session }); + + let ctx!: ReturnType; + + function Consumer() { + ctx = useAuth0(); + return null; + } + + render( + + + + ); + + return { getCtx: () => ctx }; +} + +beforeEach(() => vi.clearAllMocks()); +afterEach(cleanup); + +// ─── loginWithRedirect ──────────────────────────────────────────────────────── + +describe('SSR Auth0Provider — loginWithRedirect', () => { + it('sets window.location.href to /auth/login with no args', () => { + const { getCtx } = renderSsrProvider(); + getCtx().loginWithRedirect(); + expect(window.location.pathname).toBe('/auth/login'); + expect(window.location.search).toBe(''); + }); + + it('encodes returnTo as a query param', () => { + const { getCtx } = renderSsrProvider(); + getCtx().loginWithRedirect({ returnTo: '/dashboard' }); + expect(window.location.pathname).toBe('/auth/login'); + expect(window.location.search).toBe('?returnTo=%2Fdashboard'); + }); +}); + +// ─── logout ────────────────────────────────────────────────────────────────── + +describe('SSR Auth0Provider — logout', () => { + let submitSpy: ReturnType; + + beforeEach(() => { + submitSpy = vi + .spyOn(HTMLFormElement.prototype, 'submit') + .mockImplementation(() => {}); + }); + + afterEach(() => { + document.body.querySelectorAll('form').forEach(f => f.remove()); + }); + + it('POSTs to /auth/logout with no args', () => { + const { getCtx } = renderSsrProvider(); + getCtx().logout(); + const form = document.body.querySelector('form')!; + expect(form.method).toBe('post'); + expect(form.action).toContain('/auth/logout'); + expect(form.action).not.toContain('returnTo'); + expect(submitSpy).toHaveBeenCalledTimes(1); + }); + + it('encodes returnTo as a query param', () => { + const { getCtx } = renderSsrProvider(); + getCtx().logout({ returnTo: 'https://myapp.com' }); + const form = document.body.querySelector('form')!; + expect(form.method).toBe('post'); + expect(form.action).toContain('/auth/logout?returnTo='); + expect(submitSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/client/Auth0Provider.tsx b/src/client/Auth0Provider.tsx index fed9025..02a5a22 100644 --- a/src/client/Auth0Provider.tsx +++ b/src/client/Auth0Provider.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import type { ReactNode } from 'react'; -import { useNavigate, useSubmit, useRouteLoaderData } from 'react-router'; +import { useNavigate, useRouteLoaderData } from 'react-router'; import { Auth0Context } from './auth0-context.js'; import type { Auth0ContextValue, @@ -19,9 +19,6 @@ export interface Auth0ProviderProps { * Reads the session from the root loader — no network calls in the browser. */ function SsrAuth0Provider({ children }: Auth0ProviderProps) { - const navigate = useNavigate(); - const submit = useSubmit(); - // rootAuthLoader must be the loader for the 'root' route. // The session key holds { user } — no tokens. const rootData = useRouteLoaderData('root') as @@ -30,30 +27,35 @@ function SsrAuth0Provider({ children }: Auth0ProviderProps) { const session = rootData?.session ?? null; const user = session?.user ?? null; - // Stable callbacks: navigate and submit are already stable references from - // React Router, so these only change if the router itself changes (never in - // practice). Stability is critical because loginWithRedirect is listed in - // RequireAuth's useEffect deps — an unstable reference would re-trigger the - // redirect effect on every root re-render caused by a navigation. + // Full page navigation — must not use React Router's navigate() because: + // 1. The /auth/login loader returns a 302 redirect to Auth0 (external URL). + // 2. It sets a transaction cookie via Set-Cookie that client-side fetch ignores. + // window.location.href forces a real browser request so cookies and redirects + // are handled correctly by the browser. const loginWithRedirect = useCallback( ({ returnTo }: { returnTo?: string } = {}) => { - navigate( - returnTo - ? `/auth/login?returnTo=${encodeURIComponent(returnTo)}` - : '/auth/login' - ); + window.location.href = returnTo + ? `/auth/login?returnTo=${encodeURIComponent(returnTo)}` + : '/auth/login'; }, - [navigate] + [] ); + // Programmatic form POST — logout must be POST (GET logout can be triggered + // by third-party image tags or links, silently logging users out). + // A real form submission ensures the browser follows the 302 to Auth0's + // /v2/logout and processes the Set-Cookie that clears the session. const logout = useCallback( ({ returnTo }: { returnTo?: string } = {}) => { - const action = returnTo + const form = document.createElement('form'); + form.method = 'post'; + form.action = returnTo ? `/auth/logout?returnTo=${encodeURIComponent(returnTo)}` : '/auth/logout'; - submit({}, { method: 'post', action }); + document.body.appendChild(form); + form.submit(); }, - [submit] + [] ); // Not available in SSR mode. Deferred to Phase 2 (Token Mediating).