Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions __tests__/client/ssr-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('react-router')>();
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<typeof createMockUser> } | null = {
user: MOCK_USER
}
) {
mockRouteLoaderData.mockReturnValue({ session });

let ctx!: ReturnType<typeof useAuth0>;

function Consumer() {
ctx = useAuth0();
return null;
}

render(
<Auth0Provider>
<Consumer />
</Auth0Provider>
);

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<typeof vi.spyOn>;

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);
});
});
38 changes: 20 additions & 18 deletions src/client/Auth0Provider.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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).
Expand Down