diff --git a/package.json b/package.json index d908bff..1746644 100644 --- a/package.json +++ b/package.json @@ -41,5 +41,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "axios-mock-adapter": "^2.1.0" } } diff --git a/src/App.css b/src/App.css index df0ef57..e4053ba 100644 --- a/src/App.css +++ b/src/App.css @@ -1537,3 +1537,138 @@ } } + +/* --------------------------------------------------------------------------- + Auth pages (/login, /register, /verify-email, /account) + --------------------------------------------------------------------------- */ + +.auth-hero { + padding-bottom: 24px; +} + +.auth-wrap { + max-width: 520px; +} + +.auth-card { + background: var(--panel); + border: 1px solid var(--panel-outline); + border-radius: var(--radius-md); + box-shadow: var(--shadow); + padding: 32px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.auth-card--info { + gap: 20px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-label { + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.auth-input { + border: 1px solid var(--panel-outline); + border-radius: 14px; + padding: 13px 16px; + background: var(--panel-strong); + color: var(--text); + font-size: 1rem; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.auth-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.auth-hint { + font-size: 0.82rem; + color: var(--muted); +} + +.auth-alert { + background: rgba(229, 107, 85, 0.1); + border: 1px solid rgba(229, 107, 85, 0.35); + color: #8b2d1b; + padding: 12px 14px; + border-radius: 14px; + font-size: 0.93rem; +} + +.auth-foot { + margin: 0; + font-size: 0.92rem; + color: var(--muted); +} + +.auth-foot a { + color: var(--accent); + font-weight: 600; +} + +.auth-foot a:hover { + color: var(--accent-strong); +} + +.auth-linklike { + border: 0; + background: transparent; + padding: 0; + color: var(--accent); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.auth-linklike:hover { + color: var(--accent-strong); +} + +.auth-kv { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; +} + +.auth-kv__row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.auth-kv__row dt { + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin: 0; +} + +.auth-kv__row dd { + margin: 0; + font-weight: 500; + color: var(--text); +} + +@media (max-width: 600px) { + .auth-card { + padding: 24px 20px; + } +} diff --git a/src/App.js b/src/App.js index aacf8d6..0de2c43 100644 --- a/src/App.js +++ b/src/App.js @@ -4,6 +4,7 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import Header from './parts/Header'; import Footer from './parts/Footer'; +import PrivateRoute from './parts/PrivateRoute'; import Home from './pages/Home'; import Learn from './pages/Learn'; @@ -11,6 +12,12 @@ import Setup from './pages/Setup'; import Network from './pages/Network'; import Governance from './pages/Governance'; import Error from './pages/Error'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import VerifyEmail from './pages/VerifyEmail'; +import Account from './pages/Account'; + +import { AuthProvider } from './context/AuthContext'; function ScrollToTop() { const location = useLocation(); @@ -27,20 +34,26 @@ function ScrollToTop() { export default function App() { return ( -
- -
- - - - } /> - - - - } /> - - -
+ +
+ +
+ + + + } /> + + + + } /> + + + + + + +
+
); } diff --git a/src/App.test.js b/src/App.test.js index 2db6cd6..c465fb2 100644 --- a/src/App.test.js +++ b/src/App.test.js @@ -26,6 +26,34 @@ jest.mock('./pages/Error', () => function MockError() { return
Not found page
; }); +jest.mock('./pages/Login', () => function MockLogin() { + return
Login page
; +}); + +jest.mock('./pages/Register', () => function MockRegister() { + return
Register page
; +}); + +jest.mock('./pages/VerifyEmail', () => function MockVerify() { + return
Verify email page
; +}); + +jest.mock('./pages/Account', () => function MockAccount() { + return
Account page
; +}); + +jest.mock('./lib/authService', () => ({ + authService: { + me: () => + Promise.reject(Object.assign(new Error('unauth'), { status: 401 })), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + }, + createAuthService: jest.fn(), +})); + import App from './App'; beforeEach(() => { diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js new file mode 100644 index 0000000..011528b --- /dev/null +++ b/src/context/AuthContext.js @@ -0,0 +1,257 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { authService as defaultAuthService } from '../lib/authService'; +import { setAuthLostHandler } from '../lib/apiClient'; + +// AuthContext shape: +// +// status: 'booting' | 'anonymous' | 'authenticated' +// user: null | { id, email, emailVerified, notificationPrefs } +// login({ email, password }) -> resolves to { user } on success +// register({ email, password }) -> { status: 'verification_sent' } +// verifyEmail(token) -> { status: 'verified' } +// logout() -> null +// refresh() -> reloads /auth/me silently +// +// `booting` is the pre-first-load state while we wait for /auth/me to tell +// us whether the user already has a live session. Guards, nav chrome, etc. +// should render a neutral skeleton while booting to avoid flicker between +// "Login" and "Account" buttons on reload. +// +// Every handler throws an Error whose `.code` matches the backend's error +// codes (e.g. 'invalid_credentials', 'email_not_verified', 'email_taken', +// 'already_verified', 'invalid_or_expired_token', 'server_misconfigured'). +// Pages should prefer switching on `.code` over matching message strings. + +const AuthContext = createContext(null); + +const BOOTING = 'booting'; +const ANONYMOUS = 'anonymous'; +const AUTHENTICATED = 'authenticated'; + +export function AuthProvider({ children, authService = defaultAuthService }) { + const [status, setStatus] = useState(BOOTING); + const [user, setUser] = useState(null); + + // Guards against setting state after unmount, for pages that call + // auth methods from effects during navigation. + const mountedRef = useRef(true); + useEffect( + () => () => { + mountedRef.current = false; + }, + [] + ); + + // Request-generation counter. Every async operation that would change + // auth state (refresh / login / logout / handleAuthLost) captures the + // counter value at its START and only writes state if the value is + // unchanged at its END. Any newer operation bumps the counter, which + // atomically invalidates all in-flight older operations. + // + // This fixes the slow-network race originally caught in PR review + // (Codex round 1 P1): a mount-time /auth/me that takes several seconds + // to return 401 would otherwise kick a freshly-logged-in user back to + // /login when its failure path unconditionally forced ANONYMOUS. + const genRef = useRef(0); + const nextGen = useCallback(() => { + genRef.current += 1; + return genRef.current; + }, []); + + const safeSet = useCallback((fn, myGen) => { + if (!mountedRef.current) return; + // If `myGen` is supplied, this write is scoped to a particular async + // operation and must no-op once a newer operation has started. + if (typeof myGen === 'number' && myGen !== genRef.current) return; + fn(); + }, []); + + const refresh = useCallback(async () => { + const myGen = nextGen(); + try { + const { user: u } = await authService.me(); + safeSet(() => { + setUser(u); + setStatus(AUTHENTICATED); + }, myGen); + return u; + } catch (err) { + safeSet(() => { + setUser(null); + setStatus(ANONYMOUS); + }, myGen); + // 401 is expected (no session) — don't re-throw that. + if (err.status === 401) return null; + throw err; + } + }, [authService, safeSet, nextGen]); + + useEffect(() => { + refresh().catch(() => { + // Swallowed: refresh() already set ANONYMOUS on failure. + }); + }, [refresh]); + + const login = useCallback( + async ({ email, password }) => { + // Do NOT claim a generation before awaiting `authService.login`. + // Bumping up-front would invalidate a still-in-flight mount-time + // refresh even when credentials turn out to be rejected — the + // refresh's eventual ANONYMOUS write would then be discarded as + // stale and the app could stay stuck in `booting` on top of the + // failed-login error. (Codex round 3 P1.) + // + // Only claim the gen once we know login actually succeeded; + // anything that throws out of authService.login falls through + // with the pre-login gen intact, so the mount refresh still + // lands the correct (anonymous) state. + const res = await authService.login(email, password); + const myGen = nextGen(); + // /auth/login returns the shallow user; hit /auth/me to pick up + // emailVerified + notificationPrefs in one canonical shape. + try { + const me = await authService.me(); + safeSet(() => { + setUser(me.user); + setStatus(AUTHENTICATED); + }, myGen); + return me; + } catch (err) { + // 401 here means the Set-Cookie from /auth/login didn't stick + // — browser SameSite/secure policy, cross-origin third-party + // cookie blocking, etc. Pretending we're authenticated just + // unlocks protected routes that the very next server call + // will 401 on. Propagate as a real auth failure instead of + // falling back to the shallow login response. (Codex round 4 + // P1.) + if (err && err.status === 401) { + safeSet(() => { + setUser(null); + setStatus(ANONYMOUS); + }, myGen); + const wrapped = new Error('session_not_established'); + wrapped.code = 'session_not_established'; + wrapped.status = 401; + wrapped.cause = err; + throw wrapped; + } + // Transient failure (5xx / network blip) on the follow-up me() + // — login definitely succeeded server-side, and the cookie is + // in flight. Fall back to the shallow user from /auth/login so + // the UI doesn't punish the user for a hiccup on a best-effort + // hydration call. + safeSet(() => { + setUser(res.user); + setStatus(AUTHENTICATED); + }, myGen); + return { user: res.user }; + } + }, + [authService, safeSet, nextGen] + ); + + const register = useCallback( + async ({ email, password }) => authService.register(email, password), + [authService] + ); + + const verifyEmail = useCallback( + async (token) => authService.verifyEmail(token), + [authService] + ); + + const logout = useCallback(async () => { + // Do NOT unconditionally claim local sign-out on server failure. + // + // The earlier "swallow the error and force ANONYMOUS" design meant + // that if the /auth/logout call failed (server 500, network blip, + // CORS hiccup) we'd tell the user they were signed out while their + // session cookie was still valid — so a reload would silently + // re-authenticate them. On a shared / kiosk machine that's a real + // footgun: the user walks away believing the session is dead. + // (Codex round 5 P2.) + // + // Split the cases: + // 401 / 404 -> session is ALREADY gone server-side + // (e.g. expired cookie). Clear locally. + // anything else -> surface `logout_failed` so the caller + // can prompt retry and the UI stays in + // AUTHENTICATED until the server confirms. + try { + await authService.logout(); + } catch (err) { + const alreadyGone = + err && (err.status === 401 || err.status === 404); + if (!alreadyGone) { + const wrapped = new Error('logout_failed'); + wrapped.code = 'logout_failed'; + wrapped.status = (err && err.status) || 0; + wrapped.cause = err; + throw wrapped; + } + // fall through to clear locally — server confirmed there was + // nothing to sign out of. + } + const myGen = nextGen(); + safeSet(() => { + setUser(null); + setStatus(ANONYMOUS); + }, myGen); + }, [authService, safeSet, nextGen]); + + // Called from the apiClient's 401 interceptor when a non-auth request + // comes back unauthorized — the cookie has almost certainly expired. + // Pages react by showing "session expired, please log in". + const handleAuthLost = useCallback(() => { + const myGen = nextGen(); + safeSet(() => { + setUser(null); + setStatus(ANONYMOUS); + }, myGen); + }, [safeSet, nextGen]); + + // Register ourselves as the default apiClient's auth-loss handler so + // that 401s on protected endpoints (e.g. /vault) reach us even when + // call sites use the shared singleton instead of injecting a client. + // Clear the slot on unmount so stale providers never fire. (Codex + // round 2 P2.) + useEffect(() => { + setAuthLostHandler(handleAuthLost); + return () => setAuthLostHandler(null); + }, [handleAuthLost]); + + const value = useMemo( + () => ({ + status, + user, + isBooting: status === BOOTING, + isAuthenticated: status === AUTHENTICATED, + login, + register, + verifyEmail, + logout, + refresh, + handleAuthLost, + }), + [status, user, login, register, verifyEmail, logout, refresh, handleAuthLost] + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth() must be used inside '); + } + return ctx; +} diff --git a/src/context/AuthContext.test.js b/src/context/AuthContext.test.js new file mode 100644 index 0000000..1922d52 --- /dev/null +++ b/src/context/AuthContext.test.js @@ -0,0 +1,464 @@ +import React from 'react'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import { AuthProvider, useAuth } from './AuthContext'; + +function flush() { + // Let any pending microtasks (the refresh() promise) settle before we + // assert on rendered state. + return act(async () => { + await Promise.resolve(); + }); +} + +function HookProbe() { + const auth = useAuth(); + return ( +
+ {auth.status} + {auth.user ? auth.user.email : 'none'} + + +
+ ); +} + +function makeService(overrides = {}) { + return { + me: jest.fn().mockRejectedValue( + Object.assign(new Error('unauthorized'), { status: 401 }) + ), + login: jest.fn(), + logout: jest.fn().mockResolvedValue({ status: 'ok' }), + register: jest.fn(), + verifyEmail: jest.fn(), + ...overrides, + }; +} + +test('starts in booting, transitions to anonymous when /me returns 401', async () => { + const service = makeService(); + render( + + + + ); + expect(screen.getByTestId('status')).toHaveTextContent('booting'); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); + expect(service.me).toHaveBeenCalledTimes(1); +}); + +test('restores authenticated session when /me returns a user', async () => { + const service = makeService({ + me: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com', emailVerified: true }, + }), + }); + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + expect(screen.getByTestId('email')).toHaveTextContent('user@example.com'); +}); + +test('login transitions anonymous -> authenticated', async () => { + const service = makeService({ + login: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com' }, + expiresAt: 1, + }), + me: jest + .fn() + // Boot: no session + .mockRejectedValueOnce( + Object.assign(new Error('unauthorized'), { status: 401 }) + ) + // After login: session materialised, full profile returned + .mockResolvedValueOnce({ + user: { id: 1, email: 'user@example.com', emailVerified: true }, + }), + }); + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); + + await act(async () => { + screen.getByText('login').click(); + await flush(); + }); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + expect(service.login).toHaveBeenCalledWith('a@b.com', 'pw'); +}); + +test('logout: server success clears local state (happy path)', async () => { + const service = makeService({ + me: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com', emailVerified: true }, + }), + logout: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + + await act(async () => { + screen.getByText('logout').click(); + await flush(); + }); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); +}); + +test('logout: 401/404 on server = session already gone, clear locally (Codex round 5 P2)', async () => { + // Server says we have no session to sign out of — that's idempotent + // with our local expectation (signed out), so clearing is correct. + const service = makeService({ + me: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com', emailVerified: true }, + }), + logout: jest + .fn() + .mockRejectedValue( + Object.assign(new Error('no session'), { status: 401 }) + ), + }); + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + + await act(async () => { + screen.getByText('logout').click(); + await flush(); + }); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); +}); + +test('logout: transient server failure keeps AUTHENTICATED and rejects with logout_failed (Codex round 5 P2)', async () => { + // 5xx / network error — session cookie is almost certainly still + // valid. Pretending we're anonymous while a reload would restore + // the session is dangerous on shared machines. + const service = makeService({ + me: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com', emailVerified: true }, + }), + logout: jest + .fn() + .mockRejectedValue( + Object.assign(new Error('boom'), { status: 503 }) + ), + }); + + let capturedError = null; + + function Probe() { + const auth = useAuth(); + return ( +
+ {auth.status} + +
+ ); + } + + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + + await act(async () => { + screen.getByText('logout').click(); + await flush(); + }); + + // Still authenticated — we refuse to claim local sign-out on failure. + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + expect(capturedError).not.toBeNull(); + expect(capturedError.code).toBe('logout_failed'); + expect(capturedError.status).toBe(503); +}); + +test('stale mount-time /auth/me 401 cannot overwrite a newer successful login (Codex round 1 P1)', async () => { + // Reproduce the slow-network race: /auth/me on mount lags behind the + // user's login click. The mount's 401 arrives LATER than the login + // success and must not demote us back to anonymous. + let rejectMe; + const mePending = new Promise((_resolve, reject) => { + rejectMe = reject; + }); + const service = makeService({ + // First call: the slow mount-time probe. Leave it pending — we'll + // reject it manually mid-test. + me: jest + .fn() + .mockReturnValueOnce(mePending) + // Second call: the login-driven me() lookup. Resolves immediately. + .mockResolvedValueOnce({ + user: { id: 1, email: 'user@example.com', emailVerified: true }, + }), + login: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com' }, + expiresAt: 1, + }), + }); + + render( + + + + ); + + // We're still booting — the mount /auth/me has not resolved yet. + expect(screen.getByTestId('status')).toHaveTextContent('booting'); + + // User clicks login. It completes quickly (both authService.login and + // the login-scoped authService.me()). + await act(async () => { + screen.getByText('login').click(); + await flush(); + }); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + + // NOW the slow mount-time /auth/me finally rejects with 401. The old + // refresh() MUST NOT flip us back to anonymous — a newer operation + // (login) has already claimed the generation counter. + await act(async () => { + rejectMe(Object.assign(new Error('unauth'), { status: 401 })); + await flush(); + }); + + // Give any spurious writes a chance to land before we assert. + await flush(); + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('email')).toHaveTextContent('user@example.com'); +}); + +test('failed login does not strand the UI in booting when mount refresh is still in flight (Codex round 3 P1)', async () => { + // Setup: mount-time /auth/me is pending. User clicks login, credentials + // are rejected. The mount refresh's eventual 401 MUST still be able to + // land ANONYMOUS — the failed login must not invalidate it by bumping + // the gen counter prematurely. + let rejectMountMe; + const mountMe = new Promise((_resolve, reject) => { + rejectMountMe = reject; + }); + const service = makeService({ + me: jest.fn().mockReturnValueOnce(mountMe), + login: jest.fn().mockRejectedValue( + Object.assign(new Error('bad creds'), { + code: 'invalid_credentials', + status: 401, + }) + ), + }); + + render( + + + + ); + expect(screen.getByTestId('status')).toHaveTextContent('booting'); + + // Fail the login while the mount refresh is still pending. + await act(async () => { + screen.getByText('login').click(); + await flush(); + }); + // Login itself surfaces an error — status must still be booting, not + // authenticated. + expect(screen.getByTestId('status')).toHaveTextContent('booting'); + + // Now the mount /auth/me resolves 401. It MUST land ANONYMOUS — if the + // failed login had bumped the gen counter, this write would be dropped + // and the UI would be stuck in booting forever. + await act(async () => { + rejectMountMe(Object.assign(new Error('unauth'), { status: 401 })); + await flush(); + }); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); +}); + +test('login rejects when follow-up /auth/me returns 401 (cookie did not stick) — Codex round 4 P1', async () => { + // authService.login succeeds — credentials were valid. But the browser + // never persisted the Set-Cookie (cross-origin + third-party-cookie + // block), so the very next /auth/me comes back 401. AuthContext MUST + // treat that as a real auth failure and stay anonymous rather than + // unlock protected routes from the shallow login response. + const service = makeService({ + me: jest + .fn() + // Mount refresh: 401 (no session yet). + .mockRejectedValueOnce( + Object.assign(new Error('unauth'), { status: 401 }) + ) + // Post-login rehydrate: ALSO 401. + .mockRejectedValueOnce( + Object.assign(new Error('unauth'), { status: 401 }) + ), + login: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com' }, + expiresAt: 1, + }), + }); + + let capturedError = null; + + function Probe() { + const auth = useAuth(); + return ( +
+ {auth.status} + +
+ ); + } + + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); + + await act(async () => { + screen.getByText('login').click(); + await flush(); + }); + + expect(screen.getByTestId('status')).toHaveTextContent('anonymous'); + expect(capturedError).not.toBeNull(); + expect(capturedError.code).toBe('session_not_established'); + expect(capturedError.status).toBe(401); +}); + +test('login tolerates a 5xx/network blip on follow-up /auth/me (Codex round 4 P1)', async () => { + // Transient hiccup on the best-effort hydrate call must still land + // AUTHENTICATED using the shallow user the server already returned. + const service = makeService({ + me: jest + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('unauth'), { status: 401 }) + ) + .mockRejectedValueOnce( + Object.assign(new Error('oops'), { status: 503 }) + ), + login: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'user@example.com' }, + expiresAt: 1, + }), + }); + + render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous') + ); + + await act(async () => { + screen.getByText('login').click(); + await flush(); + }); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated') + ); + expect(screen.getByTestId('email')).toHaveTextContent('user@example.com'); +}); + +test('useAuth throws outside provider', () => { + function Bare() { + useAuth(); + return null; + } + const previousError = console.error; + // eslint-disable-next-line no-console + console.error = jest.fn(); // suppress React's boundary noise in this negative test + expect(() => render()).toThrow(/must be used inside /); + // eslint-disable-next-line no-console + console.error = previousError; +}); diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js new file mode 100644 index 0000000..d82acaa --- /dev/null +++ b/src/lib/apiClient.js @@ -0,0 +1,173 @@ +import axios from 'axios'; + +// Dedicated axios instance for the authenticated sysnode API surface +// (`/auth/*`, `/vault/*`). Kept separate from the legacy anonymous client +// in `./api.js` because: +// +// 1. This one MUST set `withCredentials: true` so the httpOnly session +// cookie and the non-httpOnly csrf cookie round-trip correctly. The +// legacy client talks to public endpoints that never see cookies. +// +// 2. It auto-attaches `X-CSRF-Token` on every state-changing request, +// reading the value from the `csrf` cookie the backend set on login. +// The backend's double-submit CSRF middleware rejects state-changing +// requests that don't carry the header. +// +// 3. It routes 401 Unauthorized responses to a single `onAuthLost` +// callback so the AuthContext can react (clear the cached user, +// redirect to /login) without every caller having to handle the +// case individually. + +// Default API base URL. +// +// DO NOT fall back to `window.location.origin`. The sysnode-info SPA +// and the sysnode-backend are hosted on different origins — the SPA +// server only serves static files, it does not proxy /auth/* anywhere +// — so defaulting to the current browser origin makes login, register, +// and session hydration silently hit the wrong host and fail unless +// an operator remembers to set `REACT_APP_API_BASE`. (Codex round 5 +// P1.) +// +// Priority: +// 1. REACT_APP_API_BASE (build-time override for bespoke deployments) +// 2. Production builds → https://syscoin.dev (same host as the legacy +// public client in `./api.js`, which is where sysnode-backend is +// reachable) +// 3. Development builds → http://localhost:3001 (backend dev server) +const DEFAULT_BASE = + process.env.REACT_APP_API_BASE || + (process.env.NODE_ENV === 'production' + ? 'https://syscoin.dev' + : 'http://localhost:3001'); + +const STATE_CHANGING = /^(POST|PUT|PATCH|DELETE)$/i; + +function readCsrfCookie() { + if (typeof document === 'undefined' || !document.cookie) return null; + // Pairs like "csrf=abc; other=value" — split on "; " to handle modern + // browsers, fall back to ";" for older serializers. + const parts = document.cookie.split(/;\s*/); + for (const p of parts) { + const eq = p.indexOf('='); + if (eq === -1) continue; + if (p.slice(0, eq) === 'csrf') { + return decodeURIComponent(p.slice(eq + 1)); + } + } + return null; +} + +// Normalise axios errors into a predictable object the UI can render +// without switching on HTTP status codes. We intentionally expose both the +// raw status and the backend's `error` code so pages can choose whichever +// they need. +function toApiError(err) { + if (err && err.response) { + const { status, data } = err.response; + const code = + (data && typeof data === 'object' && (data.error || data.code)) || + 'http_error'; + const e = new Error(code); + e.code = code; + e.status = status; + e.details = data && data.details ? data.details : null; + e.response = err.response; + return e; + } + // Network / timeout / aborted before a response. + const e = new Error('network_error'); + e.code = 'network_error'; + e.status = 0; + e.cause = err; + return e; +} + +export function createApiClient({ + baseURL = DEFAULT_BASE, + readCsrf = readCsrfCookie, + onAuthLost, +} = {}) { + // Do NOT set a global `Content-Type` here. Axios places headers from + // `create({ headers })` into its `common` bag, which is merged into + // EVERY request — including GETs with no body. Cross-origin deployments + // (REACT_APP_API_BASE pointing off-origin) treat any GET with a custom + // Content-Type as a non-simple CORS request and must first preflight + // it, so `/auth/me` on boot would fail outright if the API's OPTIONS + // response didn't whitelist Content-Type. Axios already sets the JSON + // content-type for POST/PUT/PATCH automatically when the body is an + // object, so dropping the global here loses nothing. + // + // (Codex round 3 P2.) + const instance = axios.create({ + baseURL, + withCredentials: true, + timeout: 20000, + headers: { + Accept: 'application/json, text/plain, */*', + }, + }); + + instance.interceptors.request.use(function attachCsrf(config) { + if (STATE_CHANGING.test(config.method || '')) { + const token = readCsrf(); + if (token) { + config.headers = { ...(config.headers || {}), 'X-CSRF-Token': token }; + } + } + return config; + }); + + instance.interceptors.response.use( + function passthrough(res) { + return res; + }, + function normalise(error) { + const apiError = toApiError(error); + if (apiError.status === 401 && typeof onAuthLost === 'function') { + // Don't fire on the auth endpoints themselves — a 401 on /login is + // a credential error, not a session expiry. + const url = (error.config && error.config.url) || ''; + if (!url.startsWith('/auth/')) { + try { + onAuthLost(apiError); + } catch (_) { + // Never let the callback break the error path. + } + } + } + return Promise.reject(apiError); + } + ); + + return instance; +} + +// Mutable slot for the global auth-loss handler. +// +// The default singleton `apiClient` is created at module import time — +// long before any React component (and therefore before AuthContext) +// has mounted. If we baked `onAuthLost` into that `createApiClient()` +// call directly, the closure would capture `undefined` and every 401 +// on a protected endpoint (e.g. `/vault`) would be silently dropped, +// leaving the UI in a stale "authenticated" state until the user did +// something that hit /auth/me. (Codex round 2 P2.) +// +// Instead, the singleton's onAuthLost is a thunk that reads this slot +// at error-dispatch time. AuthProvider registers its handler via +// `setAuthLostHandler` once it mounts; the slot is cleared on +// unmount so stale providers can't fire. +let globalAuthLost = null; + +export function setAuthLostHandler(fn) { + globalAuthLost = typeof fn === 'function' ? fn : null; +} + +// Default module-level client, used by simple call sites. Tests and pages +// that want to inject a mock should call `createApiClient` directly. +export const apiClient = createApiClient({ + onAuthLost: function dispatchAuthLost(err) { + if (typeof globalAuthLost === 'function') globalAuthLost(err); + }, +}); + +export { readCsrfCookie, toApiError }; diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js new file mode 100644 index 0000000..d358ec1 --- /dev/null +++ b/src/lib/apiClient.test.js @@ -0,0 +1,247 @@ +import MockAdapter from 'axios-mock-adapter'; +import { + apiClient, + createApiClient, + readCsrfCookie, + setAuthLostHandler, + toApiError, +} from './apiClient'; + +function setCookie(value) { + Object.defineProperty(document, 'cookie', { + configurable: true, + get() { + return value; + }, + }); +} + +afterEach(() => { + setCookie(''); +}); + +describe('readCsrfCookie', () => { + test('extracts csrf cookie value from document.cookie', () => { + setCookie('other=1; csrf=abcdef; trailing=x'); + expect(readCsrfCookie()).toBe('abcdef'); + }); + + test('returns null when cookie is absent', () => { + setCookie('other=1'); + expect(readCsrfCookie()).toBeNull(); + }); + + test('url-decodes cookie values', () => { + setCookie('csrf=abc%2Fdef'); + expect(readCsrfCookie()).toBe('abc/def'); + }); +}); + +describe('createApiClient — CSRF attachment', () => { + test('attaches X-CSRF-Token to state-changing methods', async () => { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => 'tok-123', + }); + const adapter = new MockAdapter(client); + adapter.onPost('/auth/login').reply(200, { ok: true }); + + const res = await client.post('/auth/login', {}); + expect(res.data).toEqual({ ok: true }); + expect(adapter.history.post[0].headers['X-CSRF-Token']).toBe('tok-123'); + }); + + test('does NOT attach the header on GET requests', async () => { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => 'tok-123', + }); + const adapter = new MockAdapter(client); + adapter.onGet('/auth/me').reply(200, {}); + + await client.get('/auth/me'); + expect(adapter.history.get[0].headers['X-CSRF-Token']).toBeUndefined(); + }); + + test('omits the header when no cookie is present', async () => { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + }); + const adapter = new MockAdapter(client); + adapter.onPost('/vault').reply(200, {}); + await client.post('/vault', {}); + expect(adapter.history.post[0].headers['X-CSRF-Token']).toBeUndefined(); + }); +}); + +describe('createApiClient — no global Content-Type (Codex round 3 P2)', () => { + // A global Content-Type would convert cross-origin GETs into non- + // simple CORS preflights and break /auth/me on boot. + test('GET requests carry no Content-Type from client defaults', async () => { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + }); + const adapter = new MockAdapter(client); + adapter.onGet('/auth/me').reply(200, {}); + + await client.get('/auth/me'); + const headers = adapter.history.get[0].headers || {}; + // Neither the per-request slot nor common should inject it. + expect(headers['Content-Type']).toBeUndefined(); + // Axios also exposes common defaults on the instance — they must + // not contain Content-Type either. + expect( + client.defaults.headers && + client.defaults.headers.common && + client.defaults.headers.common['Content-Type'] + ).toBeUndefined(); + }); + + test('POST with a JSON body still gets Content-Type from axios body inference', async () => { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + }); + const adapter = new MockAdapter(client); + adapter.onPost('/auth/login').reply(200, {}); + + await client.post('/auth/login', { email: 'a@b.com', password: 'p' }); + const ct = adapter.history.post[0].headers['Content-Type'] || ''; + expect(ct.toLowerCase()).toMatch(/^application\/json/); + }); +}); + +describe('createApiClient — 401 handling', () => { + test('invokes onAuthLost for non-auth 401s', async () => { + const onAuthLost = jest.fn(); + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + onAuthLost, + }); + const adapter = new MockAdapter(client); + adapter.onGet('/vault').reply(401, { error: 'unauthorized' }); + + await expect(client.get('/vault')).rejects.toMatchObject({ + code: 'unauthorized', + status: 401, + }); + expect(onAuthLost).toHaveBeenCalledTimes(1); + }); + + test('does NOT invoke onAuthLost for /auth/* 401s (credential errors)', async () => { + const onAuthLost = jest.fn(); + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + onAuthLost, + }); + const adapter = new MockAdapter(client); + adapter.onPost('/auth/login').reply(401, { error: 'invalid_credentials' }); + + await expect(client.post('/auth/login', {})).rejects.toMatchObject({ + code: 'invalid_credentials', + }); + expect(onAuthLost).not.toHaveBeenCalled(); + }); + + test('onAuthLost exception does not crash the error path', async () => { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => null, + onAuthLost: () => { + throw new Error('boom'); + }, + }); + const adapter = new MockAdapter(client); + adapter.onGet('/vault').reply(401, { error: 'unauthorized' }); + await expect(client.get('/vault')).rejects.toMatchObject({ + status: 401, + }); + }); +}); + +describe('default apiClient singleton — setAuthLostHandler wiring (Codex round 2 P2)', () => { + afterEach(() => { + setAuthLostHandler(null); + }); + + test('routes non-auth 401s to the registered handler', async () => { + const handler = jest.fn(); + setAuthLostHandler(handler); + + const adapter = new MockAdapter(apiClient); + adapter.onGet('/vault').reply(401, { error: 'unauthorized' }); + + await expect(apiClient.get('/vault')).rejects.toMatchObject({ + status: 401, + }); + expect(handler).toHaveBeenCalledTimes(1); + + adapter.restore(); + }); + + test('no-ops when no handler is registered (slot cleared)', async () => { + setAuthLostHandler(null); + const adapter = new MockAdapter(apiClient); + adapter.onGet('/vault').reply(401, { error: 'unauthorized' }); + + // Should reject cleanly without throwing from the interceptor. + await expect(apiClient.get('/vault')).rejects.toMatchObject({ + status: 401, + }); + adapter.restore(); + }); + + test('ignores /auth/* 401s (credential errors, not session loss)', async () => { + const handler = jest.fn(); + setAuthLostHandler(handler); + const adapter = new MockAdapter(apiClient); + adapter.onPost('/auth/login').reply(401, { error: 'invalid_credentials' }); + + await expect(apiClient.post('/auth/login', {})).rejects.toMatchObject({ + code: 'invalid_credentials', + }); + expect(handler).not.toHaveBeenCalled(); + adapter.restore(); + }); + + test('the latest registered handler wins (AuthProvider remount semantics)', async () => { + const first = jest.fn(); + const second = jest.fn(); + setAuthLostHandler(first); + setAuthLostHandler(second); + + const adapter = new MockAdapter(apiClient); + adapter.onGet('/vault').reply(401); + await expect(apiClient.get('/vault')).rejects.toBeDefined(); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + adapter.restore(); + }); +}); + +describe('toApiError', () => { + test('normalises response errors with backend error code', () => { + const e = toApiError({ + response: { status: 409, data: { error: 'already_verified' } }, + }); + expect(e.code).toBe('already_verified'); + expect(e.status).toBe(409); + }); + + test('falls back to http_error when no code present', () => { + const e = toApiError({ response: { status: 500, data: null } }); + expect(e.code).toBe('http_error'); + expect(e.status).toBe(500); + }); + + test('maps transport failures to network_error', () => { + const e = toApiError(new Error('ECONNREFUSED')); + expect(e.code).toBe('network_error'); + expect(e.status).toBe(0); + }); +}); diff --git a/src/lib/authService.js b/src/lib/authService.js new file mode 100644 index 0000000..70f900c --- /dev/null +++ b/src/lib/authService.js @@ -0,0 +1,52 @@ +import { apiClient as defaultClient } from './apiClient'; +import { deriveLoginKeys } from './crypto/kdf'; + +// High-level façade for the auth surface. +// +// The UI imports from here so it never has to know about: +// - the fact that authHash is client-derived from password+email via +// PBKDF2(600k)+HKDF, or +// - the exact HTTP endpoints / error-code contract. +// +// The `client` parameter exists purely for dependency injection in tests. +// Production code should use the default export, which is bound to the +// shared apiClient (and therefore the shared 401 interceptor). + +export function createAuthService(client = defaultClient) { + async function register(email, password) { + const { authHash } = await deriveLoginKeys(password, email); + const res = await client.post('/auth/register', { + email: email.trim(), + authHash, + }); + return res.data; + } + + async function verifyEmail(token) { + const res = await client.post('/auth/verify-email', { token }); + return res.data; + } + + async function login(email, password) { + const { authHash } = await deriveLoginKeys(password, email); + const res = await client.post('/auth/login', { + email: email.trim(), + authHash, + }); + return res.data; + } + + async function logout() { + const res = await client.post('/auth/logout'); + return res.data; + } + + async function me() { + const res = await client.get('/auth/me'); + return res.data; + } + + return { register, verifyEmail, login, logout, me }; +} + +export const authService = createAuthService(); diff --git a/src/lib/authService.test.js b/src/lib/authService.test.js new file mode 100644 index 0000000..152d8d9 --- /dev/null +++ b/src/lib/authService.test.js @@ -0,0 +1,95 @@ +import MockAdapter from 'axios-mock-adapter'; +import { createApiClient } from './apiClient'; +import { createAuthService } from './authService'; + +function makeService() { + const client = createApiClient({ + baseURL: 'http://test', + readCsrf: () => 'tok', + }); + const adapter = new MockAdapter(client); + const service = createAuthService(client); + return { service, adapter }; +} + +describe('authService.register', () => { + test('derives authHash client-side and posts normalized email', async () => { + const { service, adapter } = makeService(); + let captured; + adapter.onPost('/auth/register').reply((config) => { + captured = JSON.parse(config.data); + return [202, { status: 'verification_sent' }]; + }); + const res = await service.register(' User@Example.com ', 'hunter22a'); + expect(res).toEqual({ status: 'verification_sent' }); + expect(captured.email).toBe('User@Example.com'); + expect(captured.authHash).toMatch(/^[0-9a-f]{64}$/); + }, 20000); +}); + +describe('authService.login', () => { + test('returns user payload on success', async () => { + const { service, adapter } = makeService(); + adapter.onPost('/auth/login').reply(200, { + user: { id: 1, email: 'user@example.com' }, + expiresAt: 123456, + }); + const res = await service.login('user@example.com', 'hunter22a'); + expect(res.user).toEqual({ id: 1, email: 'user@example.com' }); + expect(res.expiresAt).toBe(123456); + }, 20000); + + test('surfaces invalid_credentials code on 401', async () => { + const { service, adapter } = makeService(); + adapter.onPost('/auth/login').reply(401, { error: 'invalid_credentials' }); + await expect(service.login('x@y.com', 'bad')).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 401, + }); + }, 20000); + + test('surfaces email_not_verified code on 403', async () => { + const { service, adapter } = makeService(); + adapter.onPost('/auth/login').reply(403, { error: 'email_not_verified' }); + await expect(service.login('x@y.com', 'hunter22a')).rejects.toMatchObject({ + code: 'email_not_verified', + }); + }, 20000); +}); + +describe('authService.verifyEmail', () => { + test('returns status:verified', async () => { + const { service, adapter } = makeService(); + adapter.onPost('/auth/verify-email').reply(200, { status: 'verified' }); + const res = await service.verifyEmail('a'.repeat(64)); + expect(res.status).toBe('verified'); + }); + + test('maps invalid_or_expired_token error', async () => { + const { service, adapter } = makeService(); + adapter + .onPost('/auth/verify-email') + .reply(400, { error: 'invalid_or_expired_token' }); + await expect(service.verifyEmail('x')).rejects.toMatchObject({ + code: 'invalid_or_expired_token', + }); + }); +}); + +describe('authService.me / logout', () => { + test('me returns user when authed', async () => { + const { service, adapter } = makeService(); + adapter.onGet('/auth/me').reply(200, { + user: { id: 1, email: 'u@e.com', emailVerified: true, notificationPrefs: {} }, + }); + const res = await service.me(); + expect(res.user.email).toBe('u@e.com'); + }); + + test('logout returns ok', async () => { + const { service, adapter } = makeService(); + adapter.onPost('/auth/logout').reply(200, { status: 'ok' }); + const res = await service.logout(); + expect(res.status).toBe('ok'); + }); +}); diff --git a/src/lib/crypto/kdf.js b/src/lib/crypto/kdf.js new file mode 100644 index 0000000..2a8e73d --- /dev/null +++ b/src/lib/crypto/kdf.js @@ -0,0 +1,170 @@ +// Client-side key derivation. +// +// Contract (must match sysnode-backend/lib/kdf.js + routes/auth.js): +// +// master = PBKDF2-SHA512(password, NFKC(email), 600_000 iter, 32 bytes) +// authHash = HKDF-SHA256(master, info="sysnode-auth-v1", 32 bytes) +// vaultKey = HKDF-SHA256(master, info="sysnode-vault-v1", salt=saltV, 32 bytes) +// +// The backend never sees `master`, `password`, or `vaultKey`. It sees only +// `authHash` (hex) during register/login and the opaque AES-GCM blob during +// vault PUT/GET. +// +// Notes: +// - PBKDF2 iteration count is intentionally fixed to match the backend +// contract. We MUST NOT change it without a coordinated migration. +// - HKDF-SHA256 with empty salt and a domain-separating `info` is a standard +// sub-key derivation pattern (RFC 5869 §3.3). +// - `vaultKey` uses HKDF with `salt=saltV` (per-user random from the server) +// so that two users with the same password still end up with different +// encryption keys for their vault blobs. + +import { normalizeEmail } from './normalize'; + +const PBKDF2_ITERATIONS = 600000; +const MASTER_BYTES = 32; +const SUBKEY_BYTES = 32; + +const AUTH_INFO = 'sysnode-auth-v1'; +const VAULT_INFO = 'sysnode-vault-v1'; + +function subtleCrypto() { + const c = + (typeof globalThis !== 'undefined' && globalThis.crypto) || + (typeof window !== 'undefined' && window.crypto); + if (!c || !c.subtle) { + throw new Error( + 'WebCrypto is unavailable. A modern browser (Chrome/Edge/Firefox/Safari) is required.' + ); + } + return c.subtle; +} + +function encodeUtf8(text) { + return new TextEncoder().encode(text); +} + +function toHex(buffer) { + const bytes = new Uint8Array(buffer); + let out = ''; + for (let i = 0; i < bytes.length; i += 1) { + out += bytes[i].toString(16).padStart(2, '0'); + } + return out; +} + +// Strict hex-to-bytes. `parseInt(x, 16)` tolerates partial parses — e.g. +// `parseInt('Ax', 16) === 10` — which would silently coerce a malformed +// saltV into a valid byte and produce a subtly wrong vault key. We guard +// the ENTIRE input against `^[0-9a-fA-F]*$` up front so any non-hex +// character anywhere throws, rather than leaking as an opaque AES-GCM +// decrypt failure downstream. (Codex round 2 P2.) +function fromHex(hex) { + if (typeof hex !== 'string' || hex.length % 2 !== 0) { + throw new Error('invalid hex'); + } + if (!/^[0-9a-fA-F]*$/.test(hex)) { + throw new Error('invalid hex'); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i += 1) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +// --------------------------------------------------------------------------- +// Master key +// --------------------------------------------------------------------------- + +export async function deriveMaster(password, email) { + if (typeof password !== 'string' || password.length === 0) { + throw new Error('password is required'); + } + const salt = encodeUtf8(normalizeEmail(email)); + if (salt.length === 0) throw new Error('email is required'); + + const subtle = subtleCrypto(); + const baseKey = await subtle.importKey( + 'raw', + encodeUtf8(password), + { name: 'PBKDF2' }, + false, + ['deriveBits'] + ); + const bits = await subtle.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-512', + salt, + iterations: PBKDF2_ITERATIONS, + }, + baseKey, + MASTER_BYTES * 8 + ); + return new Uint8Array(bits); +} + +// --------------------------------------------------------------------------- +// HKDF subkeys +// --------------------------------------------------------------------------- + +async function hkdfBytes(master, info, salt = new Uint8Array(0), length = SUBKEY_BYTES) { + const subtle = subtleCrypto(); + const baseKey = await subtle.importKey( + 'raw', + master, + { name: 'HKDF' }, + false, + ['deriveBits'] + ); + const bits = await subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt, + info: encodeUtf8(info), + }, + baseKey, + length * 8 + ); + return new Uint8Array(bits); +} + +export async function deriveAuthHash(master) { + const bytes = await hkdfBytes(master, AUTH_INFO); + return toHex(bytes); +} + +// Returns a raw AES-GCM CryptoKey ready for encrypt/decrypt. The raw bytes +// are intentionally NOT exposed — once derived they live only inside the +// WebCrypto key handle for the lifetime of the session. +export async function deriveVaultKey(master, saltV) { + const salt = typeof saltV === 'string' ? fromHex(saltV) : saltV; + const rawKey = await hkdfBytes(master, VAULT_INFO, salt, 32); + const subtle = subtleCrypto(); + return subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, // non-extractable + ['encrypt', 'decrypt'] + ); +} + +// Convenience: register/login flow. Returns both the hex authHash and the +// master bytes (so the caller can later call deriveVaultKey(master, saltV) +// without re-running the expensive PBKDF2). +export async function deriveLoginKeys(password, email) { + const master = await deriveMaster(password, email); + const authHash = await deriveAuthHash(master); + return { master, authHash }; +} + +export const __internals = { + PBKDF2_ITERATIONS, + AUTH_INFO, + VAULT_INFO, + toHex, + fromHex, +}; diff --git a/src/lib/crypto/kdf.test.js b/src/lib/crypto/kdf.test.js new file mode 100644 index 0000000..c6182e7 --- /dev/null +++ b/src/lib/crypto/kdf.test.js @@ -0,0 +1,150 @@ +import { + deriveMaster, + deriveAuthHash, + deriveVaultKey, + deriveLoginKeys, + __internals, +} from './kdf'; + +const nodeCrypto = require('crypto'); + +// Reference implementations using Node's crypto, independent of WebCrypto. +// If our WebCrypto path drifts from this, tests fail — catching a contract +// break before it ever ships. + +function nodePbkdf2(password, email, iterations = __internals.PBKDF2_ITERATIONS) { + return new Promise((resolve, reject) => { + nodeCrypto.pbkdf2( + password, + email.normalize('NFKC').trim().toLowerCase(), + iterations, + 32, + 'sha512', + (err, out) => (err ? reject(err) : resolve(new Uint8Array(out))) + ); + }); +} + +function nodeHkdfSha256(master, info, salt = Buffer.alloc(0), length = 32) { + const ikm = Buffer.from(master); + // RFC 5869 HKDF: Extract + Expand. + const prk = nodeCrypto.createHmac('sha256', salt).update(ikm).digest(); + let t = Buffer.alloc(0); + let okm = Buffer.alloc(0); + let i = 1; + while (okm.length < length) { + t = nodeCrypto + .createHmac('sha256', prk) + .update(Buffer.concat([t, Buffer.from(info, 'utf8'), Buffer.from([i])])) + .digest(); + okm = Buffer.concat([okm, t]); + i += 1; + } + return new Uint8Array(okm.subarray(0, length)); +} + +describe('kdf.deriveMaster / deriveAuthHash — cross-check vs Node crypto', () => { + const password = 'correct horse battery staple'; + const email = 'User@Example.com'; + + test('deriveMaster matches Node PBKDF2-SHA512 over NFKC+trim+lowercase email', async () => { + const [webMaster, nodeMaster] = await Promise.all([ + deriveMaster(password, email), + nodePbkdf2(password, email), + ]); + expect(webMaster).toHaveLength(32); + expect(Array.from(webMaster)).toEqual(Array.from(nodeMaster)); + }, 20000); + + test('deriveAuthHash matches Node HKDF-SHA256 with info="sysnode-auth-v1"', async () => { + const master = await nodePbkdf2(password, email); + const authHash = await deriveAuthHash(master); + + const expected = nodeHkdfSha256(master, __internals.AUTH_INFO); + const expectedHex = Buffer.from(expected).toString('hex'); + + expect(authHash).toBe(expectedHex); + expect(authHash).toMatch(/^[0-9a-f]{64}$/); + }); + + test('deriveLoginKeys returns both master and authHash for a single call', async () => { + const { master, authHash } = await deriveLoginKeys(password, email); + expect(master).toHaveLength(32); + expect(authHash).toMatch(/^[0-9a-f]{64}$/); + const direct = await deriveAuthHash(master); + expect(authHash).toBe(direct); + }, 20000); + + test('email case and padding do not affect the derived master (normalization)', async () => { + const a = await nodePbkdf2(password, ' USER@example.COM '); + const b = await nodePbkdf2(password, 'user@example.com'); + expect(Array.from(a)).toEqual(Array.from(b)); + }, 20000); + + test('empty password is rejected', async () => { + await expect(deriveMaster('', email)).rejects.toThrow(/password/i); + }); + + test('empty email is rejected', async () => { + await expect(deriveMaster(password, '')).rejects.toThrow(/email/i); + }); +}); + +describe('kdf.deriveVaultKey', () => { + test('produces a non-extractable AES-GCM key that round-trips', async () => { + const master = new Uint8Array(32).fill(7); + const saltV = 'a'.repeat(64); + const key = await deriveVaultKey(master, saltV); + expect(key.algorithm.name).toBe('AES-GCM'); + expect(key.extractable).toBe(false); + + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode('hello vault'); + const ct = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + plaintext + ); + const pt = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ct + ); + expect(new TextDecoder().decode(pt)).toBe('hello vault'); + }); + + test('rejects non-hex saltV instead of silently coercing partial parses (Codex round 2 P2)', async () => { + const master = new Uint8Array(32).fill(1); + // `Ax...` would parseInt to 10 (0xA) on the first two chars; the strict + // regex pre-check in fromHex must throw instead. + await expect( + deriveVaultKey(master, 'A'.repeat(62) + 'xx') + ).rejects.toThrow(/invalid hex/i); + // Odd-length hex: structurally malformed length. + await expect(deriveVaultKey(master, 'abc')).rejects.toThrow(/invalid hex/i); + // Trailing non-hex char inside a 2-char byte window (the exact case + // parseInt would otherwise silently coerce to 0xA). + await expect(deriveVaultKey(master, 'A0'.repeat(31) + 'AZ')).rejects.toThrow( + /invalid hex/i + ); + }); + + test('different saltV values yield different keys (sanity)', async () => { + const master = new Uint8Array(32).fill(5); + const saltA = '11'.repeat(32); + const saltB = '22'.repeat(32); + const keyA = await deriveVaultKey(master, saltA); + const keyB = await deriveVaultKey(master, saltB); + // Keys are non-extractable, so we prove divergence by encrypting the + // same plaintext with a fixed IV and checking ciphertexts differ. + const iv = new Uint8Array(12); + const pt = new TextEncoder().encode('probe'); + const ctA = new Uint8Array( + await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, keyA, pt) + ); + const ctB = new Uint8Array( + await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, keyB, pt) + ); + expect(Array.from(ctA)).not.toEqual(Array.from(ctB)); + }); +}); diff --git a/src/lib/crypto/normalize.js b/src/lib/crypto/normalize.js new file mode 100644 index 0000000..b44cf16 --- /dev/null +++ b/src/lib/crypto/normalize.js @@ -0,0 +1,35 @@ +// Email normalization MUST match sysnode-backend/lib/email.js exactly — +// the email is the PBKDF2 salt, so a divergence here makes login impossible +// across devices or after a cache clear. +// +// Rules (mirrored from the backend): +// 1. NFKC compatibility decomposition + recomposition. +// 2. Trim surrounding whitespace. +// 3. Lowercase via the invariant Unicode mapping. + +export function normalizeEmail(raw) { + if (typeof raw !== 'string') return ''; + return raw.normalize('NFKC').trim().toLowerCase(); +} + +// Permissive syntax check (also mirrored). Rejects obvious malformations but +// leaves deliverability to the verification-link round trip. +export function isValidEmailSyntax(value) { + if (typeof value !== 'string') return false; + if (value.length === 0 || value.length > 254) return false; + const atIdx = value.indexOf('@'); + if (atIdx < 1 || atIdx !== value.lastIndexOf('@')) return false; + const local = value.slice(0, atIdx); + const domain = value.slice(atIdx + 1); + if (local.length === 0 || local.length > 64) return false; + if (/\s/.test(value)) return false; + if (domain.length === 0 || domain.indexOf('.') === -1) return false; + const labels = domain.split('.'); + if (labels.some(function rejectBadLabel(l) { + return !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(l); + })) { + return false; + } + if (!/^[A-Za-z0-9._%+\-]+$/.test(local)) return false; + return true; +} diff --git a/src/lib/crypto/normalize.test.js b/src/lib/crypto/normalize.test.js new file mode 100644 index 0000000..4ac04c0 --- /dev/null +++ b/src/lib/crypto/normalize.test.js @@ -0,0 +1,43 @@ +import { normalizeEmail, isValidEmailSyntax } from './normalize'; + +describe('normalize.normalizeEmail', () => { + test('trims and lowercases', () => { + expect(normalizeEmail(' User@Example.COM ')).toBe('user@example.com'); + }); + + test('applies NFKC', () => { + expect(normalizeEmail('\u{FB01}oo@bar.com')).toBe('fioo@bar.com'); + }); + + test('is idempotent', () => { + const first = normalizeEmail('User@Example.com'); + expect(normalizeEmail(first)).toBe(first); + }); + + test('returns empty string for non-strings', () => { + expect(normalizeEmail(null)).toBe(''); + expect(normalizeEmail(undefined)).toBe(''); + expect(normalizeEmail(42)).toBe(''); + }); +}); + +describe('normalize.isValidEmailSyntax', () => { + test('accepts common formats', () => { + expect(isValidEmailSyntax('a@b.co')).toBe(true); + expect(isValidEmailSyntax('user+tag@sub.example.com')).toBe(true); + }); + + test('rejects obvious malformations', () => { + expect(isValidEmailSyntax('')).toBe(false); + expect(isValidEmailSyntax('no-at-symbol')).toBe(false); + expect(isValidEmailSyntax('two@@at.com')).toBe(false); + expect(isValidEmailSyntax('space in@email.com')).toBe(false); + expect(isValidEmailSyntax('@nouser.com')).toBe(false); + expect(isValidEmailSyntax('nodot@test')).toBe(false); + }); + + test('accepts domains whose labels start with a digit', () => { + expect(isValidEmailSyntax('user@1domain.com')).toBe(true); + expect(isValidEmailSyntax('user@sub.1domain.com')).toBe(true); + }); +}); diff --git a/src/lib/crypto/vault.js b/src/lib/crypto/vault.js new file mode 100644 index 0000000..ee40a75 --- /dev/null +++ b/src/lib/crypto/vault.js @@ -0,0 +1,132 @@ +// Vault payload format (v1): +// +// base64url( +// "SYSV1" // 5-byte magic +// | iv (12 bytes) +// | ciphertext (|plaintext| bytes) +// | tag (16 bytes, appended by AES-GCM) +// ) +// +// The plaintext is a UTF-8 JSON-serializable object. The server stores the +// resulting base64url string verbatim; it is never decrypted server-side. +// +// Design notes: +// - AES-GCM is used (authenticated encryption). 12-byte IV is the GCM default +// and matches WebCrypto convention. +// - base64url (no padding) keeps the blob HTTP-cookie/URL-safe and avoids +// needing JSON escape on the outer transport. +// - The magic prefix lets future versions (e.g. v2 with a different KDF or +// cipher) be detected without ambiguity, and catches "someone pasted a +// non-vault string into the PUT body" accidents. +// - The AES-GCM key comes from `deriveVaultKey` in `./kdf`; this module +// never touches the user's password directly. + +const MAGIC = new TextEncoder().encode('SYSV1'); +const IV_BYTES = 12; + +function subtleCrypto() { + const c = + (typeof globalThis !== 'undefined' && globalThis.crypto) || + (typeof window !== 'undefined' && window.crypto); + if (!c || !c.subtle) { + throw new Error('WebCrypto is unavailable.'); + } + return c; +} + +function concat(a, b, c) { + const out = new Uint8Array(a.length + b.length + c.length); + out.set(a, 0); + out.set(b, a.length); + out.set(c, a.length + b.length); + return out; +} + +// base64url without padding. +function toBase64Url(bytes) { + let str = ''; + for (let i = 0; i < bytes.length; i += 1) { + str += String.fromCharCode(bytes[i]); + } + const b64 = + typeof btoa === 'function' + ? btoa(str) + : Buffer.from(bytes).toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(str) { + if (typeof str !== 'string') throw new Error('blob must be a string'); + const padLen = (4 - (str.length % 4)) % 4; + const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen); + if (typeof atob === 'function') { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i); + return out; + } + return new Uint8Array(Buffer.from(b64, 'base64')); +} + +function matchesMagic(bytes) { + if (bytes.length < MAGIC.length) return false; + for (let i = 0; i < MAGIC.length; i += 1) { + if (bytes[i] !== MAGIC[i]) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +// Encrypt the vault data (arbitrary JSON-safe object) with the caller-provided +// AES-GCM CryptoKey (produced by `deriveVaultKey`). Returns a base64url string +// suitable for the `PUT /vault` body. +export async function encryptVault(plaintextObject, aesGcmKey) { + const c = subtleCrypto(); + const iv = c.getRandomValues(new Uint8Array(IV_BYTES)); + const plaintext = new TextEncoder().encode(JSON.stringify(plaintextObject)); + const ctWithTag = new Uint8Array( + await c.subtle.encrypt({ name: 'AES-GCM', iv }, aesGcmKey, plaintext) + ); + return toBase64Url(concat(MAGIC, iv, ctWithTag)); +} + +// Decrypt a blob produced by `encryptVault`. Returns the parsed JSON value. +// Throws on magic mismatch, truncation, or AES-GCM authentication failure +// (e.g. wrong key, tampered blob). +export async function decryptVault(blob, aesGcmKey) { + const all = fromBase64Url(blob); + if (!matchesMagic(all)) { + const err = new Error('invalid_vault_magic'); + err.code = 'invalid_vault_magic'; + throw err; + } + if (all.length < MAGIC.length + IV_BYTES + 16) { + const err = new Error('vault_truncated'); + err.code = 'vault_truncated'; + throw err; + } + const iv = all.slice(MAGIC.length, MAGIC.length + IV_BYTES); + const ctWithTag = all.slice(MAGIC.length + IV_BYTES); + + const c = subtleCrypto(); + let pt; + try { + pt = await c.subtle.decrypt({ name: 'AES-GCM', iv }, aesGcmKey, ctWithTag); + } catch (e) { + const err = new Error('vault_decrypt_failed'); + err.code = 'vault_decrypt_failed'; + throw err; + } + try { + return JSON.parse(new TextDecoder().decode(pt)); + } catch (e) { + const err = new Error('vault_invalid_json'); + err.code = 'vault_invalid_json'; + throw err; + } +} + +export const __internals = { toBase64Url, fromBase64Url, MAGIC, IV_BYTES }; diff --git a/src/lib/crypto/vault.test.js b/src/lib/crypto/vault.test.js new file mode 100644 index 0000000..80e1f68 --- /dev/null +++ b/src/lib/crypto/vault.test.js @@ -0,0 +1,77 @@ +import { encryptVault, decryptVault, __internals } from './vault'; +import { deriveVaultKey } from './kdf'; + +async function makeKey(fillByte = 1, saltByte = 2) { + const master = new Uint8Array(32).fill(fillByte); + const saltV = saltByte.toString(16).padStart(2, '0').repeat(32); + return deriveVaultKey(master, saltV); +} + +describe('vault encryption', () => { + test('round-trips an object through encrypt + decrypt', async () => { + const key = await makeKey(); + const data = { + version: 1, + keys: [{ label: 'Node 1', wif: 'abc' }], + }; + const blob = await encryptVault(data, key); + + expect(typeof blob).toBe('string'); + expect(blob).toMatch(/^[A-Za-z0-9_-]+$/); + + const decoded = await decryptVault(blob, key); + expect(decoded).toEqual(data); + }); + + test('each encryption produces a different ciphertext (fresh IV)', async () => { + const key = await makeKey(); + const data = { foo: 'bar' }; + const a = await encryptVault(data, key); + const b = await encryptVault(data, key); + expect(a).not.toBe(b); + }); + + test('blob carries the SYSV1 magic prefix', async () => { + const key = await makeKey(); + const blob = await encryptVault({ x: 1 }, key); + const bytes = __internals.fromBase64Url(blob); + expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe('SYSV1'); + }); + + test('decrypt fails with the wrong key', async () => { + const keyA = await makeKey(1, 1); + const keyB = await makeKey(2, 2); + const blob = await encryptVault({ x: 1 }, keyA); + await expect(decryptVault(blob, keyB)).rejects.toMatchObject({ + code: 'vault_decrypt_failed', + }); + }); + + test('decrypt rejects a tampered blob', async () => { + const key = await makeKey(); + const blob = await encryptVault({ hello: 'world' }, key); + const bytes = __internals.fromBase64Url(blob); + bytes[bytes.length - 1] ^= 0xff; + const tampered = __internals.toBase64Url(bytes); + await expect(decryptVault(tampered, key)).rejects.toMatchObject({ + code: 'vault_decrypt_failed', + }); + }); + + test('decrypt rejects a blob without the magic prefix', async () => { + const key = await makeKey(); + const junk = __internals.toBase64Url(new Uint8Array([9, 9, 9, 9, 9, 1, 2, 3])); + await expect(decryptVault(junk, key)).rejects.toMatchObject({ + code: 'invalid_vault_magic', + }); + }); + + test('decrypt rejects a truncated blob', async () => { + const key = await makeKey(); + const blob = await encryptVault({ x: 1 }, key); + const truncated = blob.slice(0, 10); + await expect(decryptVault(truncated, key)).rejects.toMatchObject({ + code: expect.stringMatching(/invalid_vault_magic|vault_truncated/), + }); + }); +}); diff --git a/src/pages/Account.js b/src/pages/Account.js new file mode 100644 index 0000000..e10bc3e --- /dev/null +++ b/src/pages/Account.js @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; + +import PageMeta from '../components/PageMeta'; +import { useAuth } from '../context/AuthContext'; + +export default function Account() { + const { user, logout } = useAuth(); + const history = useHistory(); + const [signOutError, setSignOutError] = useState(null); + const [signingOut, setSigningOut] = useState(false); + + async function onSignOut() { + // The AuthContext now surfaces `logout_failed` when the server call + // fails on anything other than 401/404 (session already gone). In + // that case the session cookie is still valid server-side, so we + // must NOT redirect to /login — doing so would tell the user they + // were signed out while a reload would restore the session. + setSignOutError(null); + setSigningOut(true); + try { + await logout(); + history.replace('/login'); + } catch (err) { + setSignOutError( + err && err.code === 'logout_failed' + ? "We couldn't end your session on the server. Please retry, or close this browser window to be safe." + : 'Sign out failed. Please retry.' + ); + } finally { + setSigningOut(false); + } + } + + return ( + <> + +
+
+ Account +

Your Sysnode account

+

+ Your voting vault and Sentry Node tooling live here. More controls + are landing soon — key import, vote history, and reminder + preferences are on the way. +

+
+
+ +
+
+
+
+
+
Email
+
{user ? user.email : ''}
+
+
+
Verified
+
+ {user && user.emailVerified ? ( + Confirmed + ) : ( + Pending + )} +
+
+
+ + {signOutError ? ( +
+ {signOutError} +
+ ) : null} + + +
+
+
+ + ); +} diff --git a/src/pages/Login.js b/src/pages/Login.js new file mode 100644 index 0000000..d57a042 --- /dev/null +++ b/src/pages/Login.js @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { Link, useHistory, useLocation } from 'react-router-dom'; + +import PageMeta from '../components/PageMeta'; +import { useAuth } from '../context/AuthContext'; +import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize'; + +const ERROR_COPY = { + invalid_credentials: + "We couldn't sign you in with that email and password. Double-check for typos and try again.", + email_not_verified: + 'Your email isn\'t verified yet. Check your inbox for the verification link, or register again to resend it.', + network_error: + 'We couldn\'t reach the sysnode server. Check your connection and try again.', + server_misconfigured: + 'The sysnode server is temporarily unavailable. Please try again in a moment.', + invalid_body: + 'Please enter a valid email and password.', + session_not_established: + "Your sign-in went through, but your browser didn't keep the session cookie. If you're using strict / third-party-cookie blocking for this site, allow it for sysnode and try again.", +}; + +function errorToCopy(code) { + return ERROR_COPY[code] || 'Something went wrong. Please try again.'; +} + +export default function Login() { + const { login } = useAuth(); + const history = useHistory(); + const location = useLocation(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + function onSubmit(event) { + event.preventDefault(); + if (submitting) return; + + const normalized = normalizeEmail(email); + if (!isValidEmailSyntax(normalized)) { + setError({ code: 'invalid_email', message: 'Please enter a valid email address.' }); + return; + } + if (password.length < 8) { + setError({ + code: 'password_too_short', + message: 'Passwords are at least 8 characters.', + }); + return; + } + + setSubmitting(true); + setError(null); + login({ email: normalized, password }) + .then(function onLoggedIn() { + const next = (location.state && location.state.from) || '/account'; + history.replace(next); + }) + .catch(function onLoginError(err) { + setError({ code: err.code || 'unknown', message: errorToCopy(err.code) }); + }) + .finally(function always() { + setSubmitting(false); + }); + } + + return ( + <> + +
+
+ Account +

Sign in

+

+ Sign in to manage Sentry Node voting keys and vote on Syscoin + governance proposals from any device. +

+
+
+ +
+
+
+
+ + +
+ +
+ + +
+ + {error ? ( +
+ {error.message} +
+ ) : null} + + + +

+ New to Sysnode? Create an account +

+
+
+
+ + ); +} diff --git a/src/pages/Login.test.js b/src/pages/Login.test.js new file mode 100644 index 0000000..5026b5b --- /dev/null +++ b/src/pages/Login.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Switch } from 'react-router-dom'; + +import Login from './Login'; +import { AuthProvider } from '../context/AuthContext'; + +jest.mock('../components/PageMeta', () => function MockPageMeta() { + return null; +}); + +function renderLogin(service, { initialPath = '/login' } = {}) { + return render( + + + + +
ACCOUNT PAGE
} /> +
+
+
+ ); +} + +function mockService(overrides = {}) { + return { + me: jest.fn().mockRejectedValue( + Object.assign(new Error('unauth'), { status: 401 }) + ), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + ...overrides, + }; +} + +test('rejects an obviously invalid email before calling the service', async () => { + const service = mockService(); + renderLogin(service); + await waitFor(() => expect(service.me).toHaveBeenCalled()); + + await userEvent.type(screen.getByLabelText(/email/i), 'not-an-email'); + await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /valid email address/i + ); + expect(service.login).not.toHaveBeenCalled(); +}); + +test('renders a friendly message when the server returns invalid_credentials', async () => { + const service = mockService({ + login: jest + .fn() + .mockRejectedValue( + Object.assign(new Error('invalid_credentials'), { + code: 'invalid_credentials', + status: 401, + }) + ), + }); + renderLogin(service); + await waitFor(() => expect(service.me).toHaveBeenCalled()); + + await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /couldn't sign you in/i + ); +}); + +test('sends the user to /account on successful login', async () => { + const service = mockService({ + login: jest.fn().mockResolvedValue({ + user: { id: 1, email: 'a@b.com' }, + expiresAt: 1, + }), + me: jest + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('unauth'), { status: 401 }) + ) + .mockResolvedValueOnce({ + user: { id: 1, email: 'a@b.com', emailVerified: true }, + }), + }); + renderLogin(service); + await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1)); + + await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a'); + await act(async () => { + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + }); + + await waitFor(() => expect(screen.getByText('ACCOUNT PAGE')).toBeInTheDocument()); + expect(service.login).toHaveBeenCalledWith('a@b.com', 'hunter22a'); +}); diff --git a/src/pages/Register.js b/src/pages/Register.js new file mode 100644 index 0000000..15370af --- /dev/null +++ b/src/pages/Register.js @@ -0,0 +1,221 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import PageMeta from '../components/PageMeta'; +import { useAuth } from '../context/AuthContext'; +import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize'; + +const ERROR_COPY = { + invalid_email: 'That email address doesn\'t look right — please check and try again.', + network_error: + 'We couldn\'t reach the sysnode server. Check your connection and try again.', + server_misconfigured: + 'The sysnode server is temporarily unavailable. Please try again in a moment.', + invalid_body: 'Please enter a valid email and a password of at least 8 characters.', +}; + +function errorToCopy(code) { + return ERROR_COPY[code] || 'Something went wrong. Please try again.'; +} + +// We intentionally enforce only a length floor on the client. The server +// never sees the password directly — it only receives the PBKDF2+HKDF +// output — so enforcing arbitrary character-class rules would add friction +// without buying anything. Users who want stronger passwords are welcome +// to use longer ones; the KDF work factor cushions the rest. +const MIN_PASSWORD_LEN = 8; + +export default function Register() { + const { register } = useAuth(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [submittedTo, setSubmittedTo] = useState(null); + + function onSubmit(event) { + event.preventDefault(); + if (submitting) return; + + const normalized = normalizeEmail(email); + if (!isValidEmailSyntax(normalized)) { + setError({ + code: 'invalid_email', + message: 'Please enter a valid email address.', + }); + return; + } + if (password.length < MIN_PASSWORD_LEN) { + setError({ + code: 'password_too_short', + message: `Password must be at least ${MIN_PASSWORD_LEN} characters.`, + }); + return; + } + if (password !== confirm) { + setError({ + code: 'password_mismatch', + message: 'The passwords you entered don\'t match.', + }); + return; + } + + setSubmitting(true); + setError(null); + + register({ email: normalized, password }) + .then(function onRegistered() { + setSubmittedTo(normalized); + }) + .catch(function onRegistrationError(err) { + setError({ code: err.code || 'unknown', message: errorToCopy(err.code) }); + }) + .finally(function always() { + setSubmitting(false); + }); + } + + if (submittedTo) { + return ( + <> + +
+
+ Almost there +

Check your inbox

+

+ If the address you entered is valid, we've sent a verification + link to {submittedTo}. Click the link within + 30 minutes to finish creating your Sysnode account. +

+
+
+
+
+
+

+ Didn't get the email? Check your spam folder, then{' '} + + . Each new request issues a fresh link; only the most recent + one you click will be redeemed. +

+

+ Already verified? Sign in +

+
+
+
+ + ); + } + + return ( + <> + +
+
+ Account +

Create your account

+

+ Your password derives a key in your browser — Sysnode never sees + or stores it. Choose something you'll remember, because a lost + password means a lost voting vault. +

+
+
+ +
+
+
+
+ + +
+ +
+ + + At least 8 characters. +
+ +
+ + +
+ + {error ? ( +
+ {error.message} +
+ ) : null} + + + +

+ Already have an account? Sign in +

+
+
+
+ + ); +} diff --git a/src/pages/Register.test.js b/src/pages/Register.test.js new file mode 100644 index 0000000..0c111b6 --- /dev/null +++ b/src/pages/Register.test.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +import Register from './Register'; +import { AuthProvider } from '../context/AuthContext'; + +jest.mock('../components/PageMeta', () => function MockPageMeta() { + return null; +}); + +function renderRegister(service) { + return render( + + + + + + ); +} + +function mockService(overrides = {}) { + return { + me: jest.fn().mockRejectedValue( + Object.assign(new Error('unauth'), { status: 401 }) + ), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn().mockResolvedValue({ status: 'verification_sent' }), + verifyEmail: jest.fn(), + ...overrides, + }; +} + +test('validates password length and mismatch client-side', async () => { + const service = mockService(); + renderRegister(service); + + await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'short'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'short'); + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + expect(await screen.findByRole('alert')).toHaveTextContent(/at least 8/i); + expect(service.register).not.toHaveBeenCalled(); + + const pw = screen.getByLabelText(/^password/i); + const cf = screen.getByLabelText(/confirm password/i); + await userEvent.clear(pw); + await userEvent.clear(cf); + await userEvent.type(pw, 'hunter22a'); + await userEvent.type(cf, 'hunter22b'); + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + expect(await screen.findByRole('alert')).toHaveTextContent(/don'?t match/i); + expect(service.register).not.toHaveBeenCalled(); +}); + +test('shows the "check your inbox" screen on success', async () => { + const service = mockService(); + renderRegister(service); + + await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'hunter22a'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'hunter22a'); + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + + await waitFor(() => + expect(screen.getByText(/check your inbox/i)).toBeInTheDocument() + ); + expect(service.register).toHaveBeenCalledWith('a@b.com', 'hunter22a'); +}); diff --git a/src/pages/VerifyEmail.js b/src/pages/VerifyEmail.js new file mode 100644 index 0000000..48f59af --- /dev/null +++ b/src/pages/VerifyEmail.js @@ -0,0 +1,169 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +import PageMeta from '../components/PageMeta'; +import { useAuth } from '../context/AuthContext'; + +const STATUS_BOOTING = 'verifying'; +const STATUS_OK = 'verified'; +const STATUS_BAD = 'invalid'; +const STATUS_ALREADY = 'already_verified'; +const STATUS_ERROR = 'error'; + +function statusFromError(err) { + switch (err && err.code) { + case 'invalid_or_expired_token': + case 'invalid_token': + return STATUS_BAD; + case 'already_verified': + return STATUS_ALREADY; + default: + return STATUS_ERROR; + } +} + +export default function VerifyEmail() { + const { verifyEmail } = useAuth(); + const location = useLocation(); + const [status, setStatus] = useState(STATUS_BOOTING); + + // We dedupe by the token value itself rather than a one-shot boolean. + // + // A one-shot guard protects against StrictMode's intentional effect + // double-invoke (each token is single-use server-side, so double + // redeem would burn a still-valid link). But it ALSO locks the page + // to whatever token it first saw — in a same-tab SPA navigation from + // `/verify-email?token=A` to `?token=B` the effect re-fires with + // fresh `location.search`, and a never-reset boolean would skip the + // new redemption silently, leaving stale status on screen until a + // hard reload. (Codex round 1 P2.) + // + // Keying the guard on the token value gives us StrictMode safety + // (second invoke with the same token is a no-op) AND correctness + // across in-app token changes (different token, different ref value, + // new redeem fires). + const lastSubmittedTokenRef = useRef(null); + // Monotonic request counter. Each dispatch captures the counter at + // its start; the .then/.catch only applies setStatus if the counter + // hasn't advanced in the meantime. Prevents a late response from a + // previous token from overwriting the status for whichever token is + // currently on screen. (Codex round 4 P2.) + const reqIdRef = useRef(0); + + useEffect( + function runVerification() { + const params = new URLSearchParams(location.search); + const token = (params.get('token') || '').trim(); + + if (lastSubmittedTokenRef.current === token) return; + lastSubmittedTokenRef.current = token; + + // Bump the counter BEFORE the malformed-token early return. + // + // If a previous valid-token request is still in flight and the + // URL changes to a malformed token, we want that stale response + // to no-op when it lands. Previously the counter only advanced + // on dispatch, so a late-returning "verified" would satisfy + // `myReqId === reqIdRef.current` and overwrite the current + // invalid-token screen. Unconditionally bumping on any new + // token (valid or not) makes every older in-flight request + // stale from this point. (Codex round 6 P2.) + const myReqId = (reqIdRef.current += 1); + + if (!/^[0-9a-f]{64}$/.test(token)) { + setStatus(STATUS_BAD); + return; + } + + setStatus(STATUS_BOOTING); + verifyEmail(token) + .then(function onVerified() { + if (myReqId !== reqIdRef.current) return; + setStatus(STATUS_OK); + }) + .catch(function onFailed(err) { + if (myReqId !== reqIdRef.current) return; + setStatus(statusFromError(err)); + }); + }, + [location.search, verifyEmail] + ); + + return ( + <> + +
+
+ Account + {status === STATUS_BOOTING ?

Verifying your email...

: null} + {status === STATUS_OK ?

Email verified

: null} + {status === STATUS_BAD ?

Verification link expired

: null} + {status === STATUS_ALREADY ?

Already verified

: null} + {status === STATUS_ERROR ?

Something went wrong

: null} +
+
+ +
+
+
+ {status === STATUS_BOOTING ? ( +

+ Hang tight — this should take just a moment. +

+ ) : null} + + {status === STATUS_OK ? ( + <> +

+ Your email is confirmed and your Sysnode account is ready. +

+ + Continue to sign in + + + ) : null} + + {status === STATUS_ALREADY ? ( + <> +

+ This account was already verified. You can go ahead and sign in. +

+ + Sign in + + + ) : null} + + {status === STATUS_BAD ? ( + <> +

+ This link is no longer valid. Verification links expire 30 + minutes after they're issued, and each one can only be + redeemed once. +

+ + Request a new link + + + ) : null} + + {status === STATUS_ERROR ? ( + <> +

+ We couldn't verify your email right now. Please try the link + again, or request a fresh one. +

+ + Request a new link + + + ) : null} +
+
+
+ + ); +} diff --git a/src/pages/VerifyEmail.test.js b/src/pages/VerifyEmail.test.js new file mode 100644 index 0000000..9a51d2b --- /dev/null +++ b/src/pages/VerifyEmail.test.js @@ -0,0 +1,281 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import VerifyEmail from './VerifyEmail'; +import { AuthProvider } from '../context/AuthContext'; + +jest.mock('../components/PageMeta', () => function MockPageMeta() { + return null; +}); + +function renderAt(search, service) { + return render( + + + + + + ); +} + +function mockService(overrides = {}) { + return { + me: jest.fn().mockRejectedValue( + Object.assign(new Error('unauth'), { status: 401 }) + ), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + ...overrides, + }; +} + +const TOKEN = 'a'.repeat(64); + +test('shows success state when server returns verified', async () => { + const service = mockService({ + verifyEmail: jest.fn().mockResolvedValue({ status: 'verified' }), + }); + renderAt(`?token=${TOKEN}`, service); + await waitFor(() => + expect(screen.getByRole('heading', { name: /email verified/i })).toBeInTheDocument() + ); + expect(service.verifyEmail).toHaveBeenCalledWith(TOKEN); +}); + +test('treats expired/invalid tokens distinctly from generic errors', async () => { + const service = mockService({ + verifyEmail: jest.fn().mockRejectedValue( + Object.assign(new Error('invalid_or_expired_token'), { + code: 'invalid_or_expired_token', + status: 400, + }) + ), + }); + renderAt(`?token=${TOKEN}`, service); + await waitFor(() => + expect(screen.getByRole('heading', { name: /link expired/i })).toBeInTheDocument() + ); +}); + +test('shows the already_verified branch on 409', async () => { + const service = mockService({ + verifyEmail: jest.fn().mockRejectedValue( + Object.assign(new Error('already_verified'), { + code: 'already_verified', + status: 409, + }) + ), + }); + renderAt(`?token=${TOKEN}`, service); + await waitFor(() => + expect(screen.getByRole('heading', { name: /already verified/i })).toBeInTheDocument() + ); +}); + +test('treats a malformed token as invalid without hitting the service', async () => { + const service = mockService(); + renderAt('?token=notlongenough', service); + await waitFor(() => + expect(screen.getByRole('heading', { name: /link expired/i })).toBeInTheDocument() + ); + expect(service.verifyEmail).not.toHaveBeenCalled(); +}); + +test('late response from a previous token cannot overwrite the current token’s status (Codex round 4 P2)', async () => { + const TOKEN_A = 'a'.repeat(64); + const TOKEN_B = 'b'.repeat(64); + + // Control token A's resolution manually. Token B rejects fast. + let resolveA; + const aPending = new Promise((resolve) => { + resolveA = resolve; + }); + + const service = mockService({ + verifyEmail: jest.fn((token) => { + if (token === TOKEN_A) return aPending; + if (token === TOKEN_B) { + return Promise.reject( + Object.assign(new Error('invalid_or_expired_token'), { + code: 'invalid_or_expired_token', + status: 400, + }) + ); + } + return Promise.reject(new Error('unexpected token')); + }), + }); + + const { createMemoryHistory } = require('history'); + const history = createMemoryHistory({ + initialEntries: [`/verify-email?token=${TOKEN_A}`], + }); + const { Router } = require('react-router-dom'); + const { render: rtlRender } = require('@testing-library/react'); + const { AuthProvider } = require('../context/AuthContext'); + const VerifyEmailComponent = require('./VerifyEmail').default; + + rtlRender( + + + + + + ); + + // Token A is in flight. Navigate to B before A resolves. + await act(async () => { + history.push(`/verify-email?token=${TOKEN_B}`); + }); + + // B's rejection lands — we should be on the invalid-token screen. + await waitFor(() => + expect( + screen.getByRole('heading', { name: /link expired/i }) + ).toBeInTheDocument() + ); + + // Now the stale A request finally resolves successfully. Its .then + // MUST be ignored — the URL is on token B, whose status is invalid. + await act(async () => { + resolveA({ status: 'verified' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + screen.getByRole('heading', { name: /link expired/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: /email verified/i }) + ).not.toBeInTheDocument(); +}); + +test('navigating from a valid token to a malformed one invalidates the in-flight request (Codex round 6 P2)', async () => { + const TOKEN_A = 'a'.repeat(64); + const MALFORMED = 'short'; + + let resolveA; + const aPending = new Promise((resolve) => { + resolveA = resolve; + }); + + const service = mockService({ + verifyEmail: jest.fn((token) => { + if (token === TOKEN_A) return aPending; + return Promise.reject(new Error('unexpected token')); + }), + }); + + const { createMemoryHistory } = require('history'); + const history = createMemoryHistory({ + initialEntries: [`/verify-email?token=${TOKEN_A}`], + }); + const { Router } = require('react-router-dom'); + const { render: rtlRender } = require('@testing-library/react'); + const { AuthProvider } = require('../context/AuthContext'); + const VerifyEmailComponent = require('./VerifyEmail').default; + + rtlRender( + + + + + + ); + + // Token A is pending. Navigate to a malformed token — the page goes + // straight to "invalid" without hitting the service. + await act(async () => { + history.push(`/verify-email?token=${MALFORMED}`); + }); + + await waitFor(() => + expect( + screen.getByRole('heading', { name: /link expired/i }) + ).toBeInTheDocument() + ); + // Service was only called for TOKEN_A. + expect(service.verifyEmail).toHaveBeenCalledTimes(1); + expect(service.verifyEmail).toHaveBeenLastCalledWith(TOKEN_A); + + // Now the stale A request resolves with success. Its .then MUST be + // invalidated even though the malformed-token branch never bumped + // the counter the old way — bumping BEFORE the early return makes + // the pending A request stale here. + await act(async () => { + resolveA({ status: 'verified' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + screen.getByRole('heading', { name: /link expired/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: /email verified/i }) + ).not.toBeInTheDocument(); +}); + +test('re-runs verification when the token query parameter changes (Codex round 1 P2)', async () => { + // Same-tab SPA navigation from one /verify-email?token=... URL to + // another must NOT reuse the first run's result. The dedupe key is + // the token value, not a one-shot boolean. + const TOKEN_A = 'a'.repeat(64); + const TOKEN_B = 'b'.repeat(64); + const service = mockService({ + verifyEmail: jest + .fn() + .mockResolvedValueOnce({ status: 'verified' }) + // Second navigation: backend says the new link is expired. + .mockRejectedValueOnce( + Object.assign(new Error('invalid_or_expired_token'), { + code: 'invalid_or_expired_token', + status: 400, + }) + ), + }); + + // Router-driven navigation: use a shared memory history so we can push + // a new URL while the page stays mounted. + const { createMemoryHistory } = require('history'); + const history = createMemoryHistory({ + initialEntries: [`/verify-email?token=${TOKEN_A}`], + }); + const { Router } = require('react-router-dom'); + const { render: rtlRender } = require('@testing-library/react'); + const { AuthProvider } = require('../context/AuthContext'); + const VerifyEmailComponent = require('./VerifyEmail').default; + + rtlRender( + + + + + + ); + + await waitFor(() => + expect( + screen.getByRole('heading', { name: /email verified/i }) + ).toBeInTheDocument() + ); + expect(service.verifyEmail).toHaveBeenCalledTimes(1); + expect(service.verifyEmail).toHaveBeenLastCalledWith(TOKEN_A); + + // Same tab, different link clicked by the user. + await act(async () => { + history.push(`/verify-email?token=${TOKEN_B}`); + }); + + await waitFor(() => expect(service.verifyEmail).toHaveBeenCalledTimes(2)); + expect(service.verifyEmail).toHaveBeenLastCalledWith(TOKEN_B); + await waitFor(() => + expect( + screen.getByRole('heading', { name: /link expired/i }) + ).toBeInTheDocument() + ); +}); diff --git a/src/parts/Header.js b/src/parts/Header.js index c25d912..131ec4d 100644 --- a/src/parts/Header.js +++ b/src/parts/Header.js @@ -2,10 +2,12 @@ import React, { useEffect, useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { EXTERNAL_LINKS, NAV_LINKS } from '../data/navigation'; +import { useAuth } from '../context/AuthContext'; export default function Header() { const [menuOpen, setMenuOpen] = useState(false); const location = useLocation(); + const { isAuthenticated, isBooting } = useAuth(); useEffect( function closeMenuOnRouteChange() { @@ -64,14 +66,23 @@ export default function Header() { > Official Docs - - Syscoin Support - + {/* Auth chip: render nothing while the initial /auth/me call + is in flight so the button doesn't flash "Sign in" for a + split second on reload when the user is in fact signed + in. Covered by Header.test.js. */} + {isBooting ? null : isAuthenticated ? ( + + Account + + ) : ( + + Sign in + + )} diff --git a/src/parts/PrivateRoute.js b/src/parts/PrivateRoute.js new file mode 100644 index 0000000..757aeb1 --- /dev/null +++ b/src/parts/PrivateRoute.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { Redirect, Route } from 'react-router-dom'; + +import { useAuth } from '../context/AuthContext'; + +// Route guard for pages that require a live session. While the initial +// /auth/me call is in flight (`isBooting`) we render a minimal placeholder +// instead of snapping to the login page, which would flicker on reload +// for users who are in fact signed in. +export default function PrivateRoute({ + children, + component: Component, + render, + ...rest +}) { + const { isAuthenticated, isBooting } = useAuth(); + + return ( + +
+
+

Checking your session...

+
+
+ + ); + } + if (!isAuthenticated) { + return ( + + ); + } + if (Component) return ; + if (typeof render === 'function') return render(routeProps); + return children; + }} + /> + ); +} diff --git a/src/parts/PrivateRoute.test.js b/src/parts/PrivateRoute.test.js new file mode 100644 index 0000000..08bc401 --- /dev/null +++ b/src/parts/PrivateRoute.test.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Switch } from 'react-router-dom'; + +import PrivateRoute from './PrivateRoute'; +import { AuthProvider } from '../context/AuthContext'; + +function mount(service, initialPath) { + return render( + + + +
LOGIN
} /> +
ACCOUNT
} /> +
+
+
+ ); +} + +test('renders the protected component when authenticated', async () => { + const service = { + me: jest + .fn() + .mockResolvedValue({ user: { id: 1, email: 'a@b.com', emailVerified: true } }), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + }; + mount(service, '/account'); + await waitFor(() => expect(screen.getByText('ACCOUNT')).toBeInTheDocument()); +}); + +test('redirects to /login when anonymous', async () => { + const service = { + me: jest.fn().mockRejectedValue( + Object.assign(new Error('unauth'), { status: 401 }) + ), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + }; + mount(service, '/account'); + await waitFor(() => expect(screen.getByText('LOGIN')).toBeInTheDocument()); +}); + +test('shows a neutral placeholder while booting', () => { + const service = { + // Never resolves, keeps provider in BOOTING state. + me: jest.fn().mockReturnValue(new Promise(() => {})), + login: jest.fn(), + logout: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + }; + mount(service, '/account'); + expect(screen.getByText(/checking your session/i)).toBeInTheDocument(); +}); diff --git a/src/setupTests.js b/src/setupTests.js index 74b1a27..7e097e6 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -3,3 +3,35 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect'; + +// TextEncoder / TextDecoder polyfill: +// jsdom 16 (bundled with react-scripts 5) doesn't expose these as globals. +// The auth/vault crypto layer needs them for UTF-8 <-> bytes round-trips. +// Node 20+ ships them on `util`; aliasing to globalThis is safe and matches +// the browser contract. +const nodeUtil = require('util'); +if (typeof globalThis.TextEncoder === 'undefined') { + globalThis.TextEncoder = nodeUtil.TextEncoder; +} +if (typeof globalThis.TextDecoder === 'undefined') { + globalThis.TextDecoder = nodeUtil.TextDecoder; +} + +// WebCrypto polyfill for jsdom: +// The auth + vault layer relies on `globalThis.crypto.subtle` (PBKDF2, HKDF, +// AES-GCM). jsdom ships a partial `crypto` stub without `subtle`, so we +// expose Node 20+'s webcrypto implementation on `globalThis.crypto`. Using +// `Object.defineProperty` avoids "Cannot redefine property" errors from the +// jsdom-provided getter while preserving the live value everywhere our +// modules reference it. +const nodeCrypto = require('crypto'); +if ( + nodeCrypto.webcrypto && + (!globalThis.crypto || !globalThis.crypto.subtle) +) { + Object.defineProperty(globalThis, 'crypto', { + value: nodeCrypto.webcrypto, + configurable: true, + writable: true, + }); +}