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) ANDopenspec/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. AcceptsAuthBackend(transport),StorageLike(token + session persistence),BroadcastChannel-like factory (multi-tab sync), optionalIdleConfig, optionalLogger, optionalClock. Loads persisted session on mount; subscribes toBroadcastChannelfor cross-tab forced-logout / refresh events; installs idle-timeout timer; never toucheswindowat import time. - ADDED:
AuthContext+useAuthContext()— raw context handle; throwsREACT_AUTH.CONTEXT_MISSINGoutside provider. - ADDED:
AuthBackendinterface — 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()— currentAuthUser | 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 atrefreshAt.
Passwords
- ADDED:
useChangePassword(),usePasswordReset()(returnsrequestReset(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-strengthassessReactivewith cancellation on unmount, renders a 5-bucket bar with i18n-keyed feedback strings. - ADDED:
<HibpPwnedIndicator>— wraps@suaveplan/hibpk-anonymity check (caller injects the client; debounced ≥ 500 ms; no network on SSR). - ADDED:
<PasswordGeneratorButton>— opens a@suaveplan/password-generatorpopover; emits chosen password via callback.
Passkeys (WebAuthn)
- ADDED:
useRegisterPasskey()— wrapswebauthn.registerwith the typed options/result + error translation. - ADDED:
useAuthenticateWithPasskey()— wrapswebauthn.authenticate(modal mediation:"required"or unset). - ADDED:
usePasskeyAutofill()— wrapswebauthn.authenticate({ mediation: "conditional" })with the lifecycle from the superseded passkey-autofill design (SSR-gated,isConditionalMediationAvailablefeature detect, composedAbortController, stableabortcallback). - 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 forpostMessagefrom the callback page, exchanges code → tokens via the backend, closes the popup. Redirect mode persists return-state and callswindow.location.assign. - ADDED:
<OAuthProviderButton>— accessible button with provider-branded icon + i18n-keyed label; usesuseOAuthSignInunder the hood. - ADDED:
OAuthProviderRegistry— name-keyed registry ofOAuthProviderDescriptor(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-useswebauthn.registerwith 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/sessionStorageadapters bundled). - ADDED: auto-refresh — provider installs a timer firing at
refreshAt = expiresAt - skewMsvia injectedClock. - ADDED: multi-tab sync — provider opens a
BroadcastChannel("@suaveplan/react-auth")(factory injectable for SSR / test); emitssigned-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>exposesforceLogout(reason); broadcasts to other tabs.
Guards
- ADDED:
<AuthGuard>— renders children when authenticated; otherwise rendersfallback(default: throwsREACT_AUTH.UNAUTHENTICATEDfor the nearest error boundary). - ADDED:
<PermissionGate>— role / permission gating using injectedPermissionResolver. - 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 underlyingcause. - ADDED:
<AuthErrorBoundary>— React error boundary that catchesSuaveplanError<*REACT_AUTH.*>and renders an i18n-keyed user-facing message via the injectedMessageResolver; unrecognised errors re-throw. - ADDED: i18n catalog —
en-GB(canonical),cy-GB,zh-CN(parity-checked viabun scripts/check-i18n-parity.ts); message keys namespaced underreact-auth.*.
SSR safety
- Every hook returns a stable no-op shape during SSR (
typeof window === "undefined"); zerowindow/navigator/documentaccess at module load. <AuthProvider>resolves persisted-session loading insideuseEffect, 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-describedbyfor help/error text,aria-live="polite"for status changes, keyboard focus management on dialog open/close,prefers-reduced-motionrespected 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.tsxfor 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-domrender 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'sverifyAttestation/verifyAssertionrun 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/qrcodealready exists;<TotpEnrollmentPanel>consumes it via an injectablerenderQrCodeprop rather than re-implementing.
Supersedes
On archival of complete-react-auth, the following are folded in / superseded and moved aside:
openspec/changes/enterprise-react-auth/— narrow OAuth-only scaffold. Every requirement it would have specified is covered here in expanded form. Move toopenspec/changes/_superseded-narrow-deferrals-2026-05-17/enterprise-react-auth/as part of this proposal landing.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 indesign.mdandspecs/react-auth/spec.mdhere.
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.