Files
2026-05-27 23:29:53 +01:00

20 KiB

OpenSpec Change: complete-react-auth

  • Change ID: complete-react-auth
  • Package: @suaveplan/react-auth (NEW)
  • Target path: packages/auth/react-auth/
  • Tier: browser (React 19 peer)
  • Status: PROPOSED
  • Date: 2026-05-17
  • Design: ./design.md
  • Spec delta: ./specs/react-auth/spec.md
  • Supersedes: openspec/changes/enterprise-react-auth/ (narrow OAuth-only scaffold) AND openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-react-auth-passkey-autofill/ (passkey-autofill-only follow-up). Both are folded in.
  • Upstream reference: /mnt/s/web/suaveplan/enterprise/react-auth.md
  • Upstream source: /mnt/s/web/suaveplan/packages/react/react-auth/src/ (Better-Auth-bound; pages, forms, guards, three translation packs, E2E)

Why

@suaveplan/react-auth is the React surface for every authentication interaction in the genesis stack. The two pre-existing proposals each scoped a single slice (OAuth context-only in enterprise-react-auth; conditional-UI passkey autofill in add-react-auth-passkey-autofill) and deliberately left every other auth flow — passwords, passkey registration & modal authentication, MFA, OAuth providers beyond a token exchange, session refresh, multi-tab sync, email/phone verification, error boundaries, accessibility, i18n — on a "follow-up" pile. The user has explicitly rejected that pattern: shipping one piece at a time produces a package that consumers cannot adopt without hand-rolling the rest, which defeats the purpose of a react-auth package existing.

This change lands the entire intended React-auth surface in one shot at enterprise quality (100% coverage, sibling .md per file, Storybook, Playwright, accessibility, SSR-safe, i18n-keyed). Every primitive the package needs already ships on genesis: @suaveplan/webauthn (register / authenticate / isConditionalMediationAvailable), @suaveplan/oauth-client (Authorization Code + PKCE, refresh, revoke, discovery, provider presets), @suaveplan/srp (zero-knowledge password proof), @suaveplan/otp (HOTP / TOTP / recovery codes), @suaveplan/hibp (k-anonymity breach check), @suaveplan/password-strength (zxcvbn-style estimator), @suaveplan/password-generator, @suaveplan/error, @suaveplan/events, @suaveplan/logger, @suaveplan/i18n, @suaveplan/react-ui. The React layer is the last missing piece.

Consumers (@suaveplan/web-app, downstream tenant apps) need a single import surface that wires those primitives into hooks and components: <AuthProvider> for context, hooks for every state read and every lifecycle mutation, components for the canonical UX (password input with strength + HIBP, passkey autofill field, passkey list with revoke, MFA enrollment, recovery codes, OAuth button, verification flows), an error boundary that turns typed AuthError codes into friendly i18n-keyed messages, multi-tab session sync via BroadcastChannel, and SSR-safe default behaviour everywhere. Shipping anything less forces every consumer to write the same wiring badly.

What changes

NEW package: @suaveplan/react-auth

Browser-tier React 19 package at packages/auth/react-auth/. Single import surface; every feature lives behind a tree-shakeable subpath export. No global state, no required server. Pure-client logic; the caller wires AuthBackend (transport adapter) to whatever server they run.

Context + provider

  • ADDED: <AuthProvider> — top-level provider. Accepts AuthBackend (transport), StorageLike (token + session persistence), BroadcastChannel-like factory (multi-tab sync), optional IdleConfig, optional Logger, optional Clock. Loads persisted session on mount; subscribes to BroadcastChannel for cross-tab forced-logout / refresh events; installs idle-timeout timer; never touches window at import time.
  • ADDED: AuthContext + useAuthContext() — raw context handle; throws REACT_AUTH.CONTEXT_MISSING outside provider.
  • ADDED: AuthBackend interface — every server call (password sign-in, sign-up, password-reset request + confirm, change password, email-verify request + confirm, phone-verify request + confirm, OAuth code exchange, OAuth refresh, OAuth revoke, passkey register options + finish, passkey assertion options + finish, list passkeys, revoke passkey, TOTP enroll begin + finish, TOTP verify, recovery-codes generate + verify, SRP exchange, session refresh, session revoke). Caller implements; the package never assumes a wire format.

State + lifecycle hooks

  • ADDED: useAuth() — full context value (state + every mutation).
  • ADDED: useUser() — current AuthUser | null.
  • ADDED: useIsAuthenticated() — boolean.
  • ADDED: useSession() — { user, accessToken, expiresAt, refreshAt, status }.
  • ADDED: useSignIn(), useSignUp(), useSignOut() — async mutation hooks with { mutate, status, error, reset }.
  • ADDED: useRefreshToken() — manual refresh; the provider also auto-refreshes via timer at refreshAt.

Passwords

  • ADDED: useChangePassword(), usePasswordReset() (returns requestReset(email) + confirmReset(token, newPassword)).
  • ADDED: <PasswordInput> — accessible input with show/hide toggle, optional integrated <PasswordStrengthMeter> and HIBP-pwned check (debounced, opt-in).
  • ADDED: <PasswordStrengthMeter> — wraps @suaveplan/password-strength assessReactive with cancellation on unmount, renders a 5-bucket bar with i18n-keyed feedback strings.
  • ADDED: <HibpPwnedIndicator> — wraps @suaveplan/hibp k-anonymity check (caller injects the client; debounced ≥ 500 ms; no network on SSR).
  • ADDED: <PasswordGeneratorButton> — opens a @suaveplan/password-generator popover; emits chosen password via callback.

Passkeys (WebAuthn)

  • ADDED: useRegisterPasskey() — wraps webauthn.register with the typed options/result + error translation.
  • ADDED: useAuthenticateWithPasskey() — wraps webauthn.authenticate (modal mediation: "required" or unset).
  • ADDED: usePasskeyAutofill() — wraps webauthn.authenticate({ mediation: "conditional" }) with the lifecycle from the superseded passkey-autofill design (SSR-gated, isConditionalMediationAvailable feature detect, composed AbortController, stable abort callback).
  • ADDED: <PasskeyAutofillField> — <input autocomplete="username webauthn"> wrapper, blur-cancels when no credential delivered.
  • ADDED: <PasskeyList> — lists the user's registered authenticators with friendly name, last-used timestamp, and a per-row revoke button (accessible confirm dialog).

OAuth

  • ADDED: useOAuthSignIn() — signIn({ provider, mode: "popup" | "redirect", scopes? }). Popup mode opens a centred window, waits for postMessage from the callback page, exchanges code → tokens via the backend, closes the popup. Redirect mode persists return-state and calls window.location.assign.
  • ADDED: <OAuthProviderButton> — accessible button with provider-branded icon + i18n-keyed label; uses useOAuthSignIn under the hood.
  • ADDED: OAuthProviderRegistry — name-keyed registry of OAuthProviderDescriptor (label key, icon, scopes). Caller registers providers at provider-setup time.
  • ADDED: handleOAuthCallback() — page-level helper for the redirect-callback URL; parses query, validates state, exchanges code, posts result back to the opener (popup mode) or persists session + navigates (redirect mode).

MFA

  • ADDED: useEnrollTotp() — begin() returns secret + provisioning URI (caller renders QR via @suaveplan/qrcode); finish(code) verifies.
  • ADDED: useVerifyTotp() — sign-in second factor.
  • ADDED: useEnrollWebauthnSecondFactor() — re-uses webauthn.register with the "cross-platform" | "platform" selector.
  • ADDED: useRecoveryCodes() — generate(), verify(code).
  • ADDED: <TotpEnrollmentPanel> — orchestrates secret display + code entry + recovery-codes reveal in one accessible flow.
  • ADDED: <RecoveryCodesPanel> — accessible list with copy-to-clipboard + download-as-text + acknowledge-saved checkbox before dismiss.

Email / phone verification

  • ADDED: useEmailVerification() — request(), confirm(token).
  • ADDED: usePhoneVerification() — request(), confirm(code).
  • ADDED: <EmailVerificationView> — renders the "check your inbox" UX with resend cooldown timer.
  • ADDED: <PhoneVerificationView> — code-entry input optimised for mobile (numeric, autocomplete=one-time-code).

Session lifecycle

  • ADDED: token persistence via injected StorageLike (memory / localStorage / sessionStorage adapters bundled).
  • ADDED: auto-refresh — provider installs a timer firing at refreshAt = expiresAt - skewMs via injected Clock.
  • ADDED: multi-tab sync — provider opens a BroadcastChannel("@suaveplan/react-auth") (factory injectable for SSR / test); emits signed-in, signed-out, session-refreshed, forced-logout; receivers update local state synchronously.
  • ADDED: idle timeout — optional IdleConfig ({ ttlMs, warnBeforeMs, onWarn, onTimeout }) triggers automatic sign-out via DOM activity listeners (pointer/key/visibility); accessibility-friendly warning callback so consumers can render a modal.
  • ADDED: forced-logout — <AuthProvider> exposes forceLogout(reason); broadcasts to other tabs.

Guards

  • ADDED: <AuthGuard> — renders children when authenticated; otherwise renders fallback (default: throws REACT_AUTH.UNAUTHENTICATED for the nearest error boundary).
  • ADDED: <PermissionGate> — role / permission gating using injected PermissionResolver.
  • ADDED: withAuth(Component), usePermission(name), useAuthGuard() — hook/HOC variants for the two guards.

Error UX + i18n

  • ADDED: defineCodes("REACT_AUTH", [...]) registry — CONTEXT_MISSING, UNAUTHENTICATED, BACKEND_ERROR, SIGN_IN_FAILED, SIGN_UP_FAILED, SIGN_OUT_FAILED, REFRESH_FAILED, PASSWORD_TOO_WEAK, PASSWORD_PWNED, PASSWORD_RESET_FAILED, CHANGE_PASSWORD_FAILED, PASSKEY_REGISTER_FAILED, PASSKEY_AUTHENTICATE_FAILED, PASSKEY_AUTOFILL_UNAVAILABLE, PASSKEY_AUTOFILL_FAILED, PASSKEY_AUTOFILL_ABORTED, OAUTH_POPUP_BLOCKED, OAUTH_STATE_MISMATCH, OAUTH_CALLBACK_FAILED, MFA_REQUIRED, MFA_VERIFY_FAILED, TOTP_ENROLL_FAILED, RECOVERY_CODE_INVALID, EMAIL_VERIFY_FAILED, PHONE_VERIFY_FAILED, INVALID_OPTIONS, SSR_NO_OP, IDLE_TIMEOUT, FORCED_LOGOUT. Every code reachable from a real call site; every translation preserves underlying cause.
  • ADDED: <AuthErrorBoundary> — React error boundary that catches SuaveplanError<*REACT_AUTH.*> and renders an i18n-keyed user-facing message via the injected MessageResolver; unrecognised errors re-throw.
  • ADDED: i18n catalog — en-GB (canonical), cy-GB, zh-CN (parity-checked via bun scripts/check-i18n-parity.ts); message keys namespaced under react-auth.*.

SSR safety

  • Every hook returns a stable no-op shape during SSR (typeof window === "undefined"); zero window / navigator / document access at module load.
  • <AuthProvider> resolves persisted-session loading inside useEffect, so the server render is deterministic.
  • Subpath exports stay tree-shakeable; importing the OAuth surface alone does not pull WebAuthn.

Accessibility

  • Every component meets WCAG 2.1 AA: explicit <label> association, aria-describedby for help/error text, aria-live="polite" for status changes, keyboard focus management on dialog open/close, prefers-reduced-motion respected on transitions, contrast ratios verified in stories.
  • Live-region announcements on sign-in success/failure and on idle-timeout warning.

Storybook + tests + E2E

  • ADDED: .stories.tsx for every component (default + playground + variants including unauthenticated / loading / error / RTL / dark).
  • ADDED: Vitest tests for every hook and component using @suaveplan/testing/runner (rule 11) and @suaveplan/testing-dom render helpers.
  • ADDED: Playwright e2e/ covering: password sign-in, sign-up, password reset, change-password, passkey register, passkey autofill, modal passkey authenticate, OAuth popup, OAuth redirect, TOTP enroll + verify, recovery-code redeem, email verification, phone verification, multi-tab sign-out propagation, idle-timeout sign-out, error-boundary fallback rendering, RTL locale rendering.

Subpath exports

./auth-provider, ./hooks/use-auth, ./hooks/use-user, … (one per hook), ./components/auth-guard, ./components/permission-gate, ./components/password-input, ./components/password-strength-meter, ./components/hibp-pwned-indicator, ./components/password-generator-button, ./components/passkey-autofill-field, ./components/passkey-list, ./components/oauth-provider-button, ./components/totp-enrollment-panel, ./components/recovery-codes-panel, ./components/email-verification-view, ./components/phone-verification-view, ./components/auth-error-boundary, ./oauth/handle-callback, ./oauth/provider-registry, ./session/storage, ./session/broadcast-channel, ./session/idle-timer, ./errors, ./i18n/en-gb, ./i18n/cy-gb, ./i18n/zh-cn.

Dependencies

  • @suaveplan/types workspace:^ — StorageLike, Clock, LoggerLike, IdGenerator, MessageResolver.
  • @suaveplan/error workspace:^ — SuaveplanError, defineCodes.
  • @suaveplan/events workspace:^ — internal event bus for auth lifecycle.
  • @suaveplan/logger workspace:^ — structured logging.
  • @suaveplan/i18n workspace:^ — message resolution.
  • @suaveplan/webauthn workspace:^ — passkey ceremonies.
  • @suaveplan/oauth-client workspace:^ — OAuth + OIDC.
  • @suaveplan/srp workspace:^ — SRP-6a password proof (optional sign-in path).
  • @suaveplan/otp workspace:^ — TOTP + recovery codes.
  • @suaveplan/hibp workspace:^ — pwned-password check.
  • @suaveplan/password-strength workspace:^ — strength estimator.
  • @suaveplan/password-generator workspace:^ — passphrase / password generator.
  • @suaveplan/react-ui workspace:^ — button, dialog, input primitives.
  • react ^19.0.0 (peer), react-dom ^19.0.0 (peer).
  • zod catalog:forms, tslib catalog:build.

Source mapping (upstream → genesis)

Upstream (/mnt/s/web/suaveplan/packages/react/react-auth/src/) Genesis (packages/auth/react-auth/src/) Notes
client/auth-client.ts (Better-Auth wrapper) (replaced) auth-backend/ interface Genesis transport-agnostic; consumer wires backend. Removes @ts-expect-error (upstream gap §3).
components/auth-provider/ auth-provider/ Re-architected for multi-tab + idle + injectable Clock/Storage.
context/auth-context.tsx auth-provider/auth-context.ts Inlined into provider surface; explicit useAuthContext() accessor.
components/auth-guard/, hooks/use-auth-guard.ts auth-guard/ Adds typed-error fallback; keeps HOC withAuth.
components/permission-gate/, hooks/use-permission.ts permission-gate/ Adds injectable PermissionResolver.
components/login-form/, register-form/, forgot-password-form/, reset-password-form/, email-verification-view/ components/<form>/ Each gains its own test (upstream gap §3); composed from <PasswordInput> etc.
pages/{login,register,forgot-password,reset-password,email-verification,profile}/ (out) Page-level orchestration belongs to the consuming app — this package ships the reusable hooks + components only (eliminates the upstream pages/forms duplication noted in enterprise/react-auth.md §3).
translations/{en-gb,cy-gb,zh-cn}.ts i18n/{en-gb,cy-gb,zh-cn}/ Parity-checked at CI.
lib/better-auth-adapter.ts (replaced) example in README.md Better Auth is one possible backend; the canonical interface is AuthBackend.
types/ types/ Trimmed to active types; crud.types.ts moved out (belongs to consuming-app boilerplate, not auth).
n/a (new) use-passkey-autofill/, passkey-autofill-field/ Folded in from add-react-auth-passkey-autofill (superseded).
n/a (new) TOTP / recovery / passkey-register / passkey-list / OAuth popup + redirect / idle timer / broadcast-channel sync / pwned indicator / strength meter / password-generator-button / phone verification Closes the upstream "feature-rich enhancements" list (enterprise/react-auth.md §4) — MFA, passkeys, SSO orchestration, device management, telemetry, accessibility polish — and the user's explicit no-deferral list.

Verification approach

Each ### Requirement in specs/react-auth/spec.md carries a **Verified by:** line at draft time naming the planned test file and test-name (CLAUDE.md §0c / OpenSpec §12a). Coverage gate: 100/100/100/100 Istanbul (rule 7). Test runner: @suaveplan/testing/runner (rule 11). Real-DOM hook + component testing via @suaveplan/testing-dom. Playwright E2E gates the multi-tab + popup-OAuth + idle-timeout flows that JSDOM cannot exercise. Accessibility verified per component via @axe-core/playwright in the E2E sweep.

Impact

Affected dependents

This is a NEW package. Run bunx turbo ls --filter=...@suaveplan/react-auth after landing.

  • No dependents at landing time (verified by bunx turbo ls --filter=...@suaveplan/react-auth — currently empty). Adopters (@suaveplan/web-app, downstream tenant apps) will migrate in their own follow-up changes.

Foundation packages touched

  • None modified. Every dependency (webauthn, oauth-client, srp, otp, hibp, password-strength, password-generator, i18n, react-ui, error, events, logger, types) is consumed at its current published surface.

Out of scope

Strictly limited to capabilities that belong to other packages, not deferred react-auth UX. The user explicitly rejected scoping any react-auth feature out.

  • Server-side session storage — lives in @suaveplan/server-session (separate package). This package is browser-tier; sessions on the server are the server's responsibility.
  • Server-side OAuth callback handling on a different origin — handled by the consumer's API route or @suaveplan/api-auth-* plugins. This package handles the browser-side callback page only.
  • WebAuthn server verification — @suaveplan/webauthn's verifyAttestation / verifyAssertion run on the server. The React hook only invokes the ceremony; the backend (caller-implemented) calls the verifier.
  • Backend transport implementation — the package defines AuthBackend; concrete implementations against Better Auth / Hono routes / tRPC / GraphQL live in consumer code or sibling adapter packages.
  • Page-level orchestration — <LoginPage>, <ProfilePage> etc. live in the consuming app. This package ships hooks + composable components; the app stitches them.
  • QR code rendering — @suaveplan/qrcode already exists; <TotpEnrollmentPanel> consumes it via an injectable renderQrCode prop rather than re-implementing.

Supersedes

On archival of complete-react-auth, the following are folded in / superseded and moved aside:

  1. openspec/changes/enterprise-react-auth/ — narrow OAuth-only scaffold. Every requirement it would have specified is covered here in expanded form. Move to openspec/changes/_superseded-narrow-deferrals-2026-05-17/enterprise-react-auth/ as part of this proposal landing.
  2. openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-react-auth-passkey-autofill/ — already moved aside in a prior superseding wave; its design notes for the autofill lifecycle are reused verbatim in design.md and specs/react-auth/spec.md here.

Migration

No prior shipped surface on genesis — additive only. The two superseded change folders never archived, so there is no canonical spec to migrate from. Future consumers adopt @suaveplan/react-auth directly.