# Design — `@suaveplan/react-auth` (complete-react-auth) Non-normative. Architecture, lifecycle, prop tables, and rationale. The normative contract lives in `specs/react-auth/spec.md`. ## Package shape `@suaveplan/react-auth` is a browser-tier React 19 package at `packages/auth/react-auth/`. It composes the existing genesis auth primitives (`webauthn`, `oauth-client`, `srp`, `otp`, `hibp`, `password-strength`, `password-generator`) into hooks and components that React apps can drop in. There is no global singleton; everything is rooted in `` and tree-shakeable via subpath exports. ## File layout ```text packages/auth/react-auth/ README.md package.json tsconfig.json vitest.config.ts playwright.config.ts src/ index.ts # explicit named re-exports (rule 6a) types/ auth-user.ts / auth-user.md auth-state.ts / auth-state.md auth-backend.ts / auth-backend.md session.ts / session.md idle-config.ts / idle-config.md permission-resolver.ts / permission-resolver.md errors/ index.ts errors.ts / errors.md / errors.test.ts schemas/ schemas.ts / schemas.md / schemas.test.ts # Zod boundary schemas for every hook option bag auth-provider/ auth-provider.tsx / .md / .test.tsx / .stories.tsx auth-context.ts / .md / .test.ts use-auth-context.ts / .md / .test.ts load-persisted-session.ts / .md / .test.ts hooks/ use-auth/ use-user/ use-is-authenticated/ use-session/ use-sign-in/ use-sign-up/ use-sign-out/ use-refresh-token/ use-change-password/ use-password-reset/ use-register-passkey/ use-authenticate-with-passkey/ use-passkey-autofill/ use-oauth-sign-in/ use-enroll-totp/ use-verify-totp/ use-enroll-webauthn-second-factor/ use-recovery-codes/ use-email-verification/ use-phone-verification/ use-permission/ use-auth-guard/ components/ auth-guard/ permission-gate/ password-input/ password-strength-meter/ hibp-pwned-indicator/ password-generator-button/ passkey-autofill-field/ passkey-list/ oauth-provider-button/ totp-enrollment-panel/ recovery-codes-panel/ email-verification-view/ phone-verification-view/ auth-error-boundary/ oauth/ handle-callback.ts / .md / .test.ts provider-registry.ts / .md / .test.ts popup.ts / .md / .test.ts redirect.ts / .md / .test.ts session/ storage/ # memory / local / session adapters broadcast-channel.ts / .md / .test.ts idle-timer.ts / .md / .test.ts auto-refresh.ts / .md / .test.ts forced-logout.ts / .md / .test.ts utils/ compose-signals.ts / .md / .test.ts is-ssr.ts / .md / .test.ts announce.ts / .md / .test.ts # ARIA live-region helper i18n/ en-gb/ cy-gb/ zh-cn/ # message catalogues parity.test.ts # key-set parity gate e2e/ password-sign-in.spec.ts sign-up.spec.ts password-reset.spec.ts change-password.spec.ts passkey-register.spec.ts passkey-autofill.spec.ts passkey-modal-authenticate.spec.ts oauth-popup.spec.ts oauth-redirect.spec.ts totp-enroll-verify.spec.ts recovery-codes.spec.ts email-verification.spec.ts phone-verification.spec.ts multi-tab-sync.spec.ts idle-timeout.spec.ts error-boundary.spec.ts rtl-locale.spec.ts accessibility.spec.ts ``` ## Context shape ```ts interface AuthState { status: "loading" | "authenticated" | "unauthenticated" | "mfa-required"; user: AuthUser | null; accessToken: string | null; expiresAt: number | null; // epoch ms refreshAt: number | null; // epoch ms (= expiresAt - skewMs) error: SuaveplanError | null; } interface AuthContextValue { state: AuthState; signIn(opts: PasswordSignInOptions | SrpSignInOptions | PasskeySignInOptions): Promise; signUp(opts: SignUpOptions): Promise; signOut(opts?: SignOutOptions): Promise; refresh(): Promise; forceLogout(reason: string): void; backend: AuthBackend; resolveMessage: MessageResolver; permissions: PermissionResolver; } ``` ## AuthBackend interface (caller-implemented) ```ts interface AuthBackend { passwordSignIn(p: { email: string; password: string }): Promise; srpSignIn(p: SrpHandshakeIn): Promise; signUp(p: SignUpRequest): Promise; requestPasswordReset(p: { email: string }): Promise; confirmPasswordReset(p: { token: string; newPassword: string }): Promise; changePassword(p: { currentPassword: string; newPassword: string }): Promise; requestEmailVerification(): Promise; confirmEmailVerification(p: { token: string }): Promise; requestPhoneVerification(p: { phone: string }): Promise; confirmPhoneVerification(p: { code: string }): Promise; oauthExchange(p: { code: string; state: string; provider: string; codeVerifier: string }): Promise; oauthRefresh(p: { refreshToken: string }): Promise; oauthRevoke(p: { token: string }): Promise; passkeyRegisterOptions(p: { userId: string }): Promise; passkeyRegisterFinish(p: { credential: RegisterResult }): Promise; passkeyAuthenticateOptions(p: { userHint?: string; mediation: "required" | "conditional" }): Promise; passkeyAuthenticateFinish(p: { assertion: AuthenticateResult }): Promise; listPasskeys(): Promise; revokePasskey(p: { credentialId: string }): Promise; totpEnrollBegin(): Promise<{ secret: string; provisioningUri: string }>; totpEnrollFinish(p: { code: string }): Promise<{ recoveryCodes: string[] }>; totpVerify(p: { code: string }): Promise; recoveryCodesGenerate(): Promise<{ codes: string[] }>; recoveryCodesVerify(p: { code: string }): Promise; sessionRefresh(): Promise; sessionRevoke(): Promise; } ``` ## Hook signatures (summary table) | Hook | Signature | |---|---| | `useAuth` | `() => AuthContextValue` | | `useUser` | `() => AuthUser \| null` | | `useIsAuthenticated` | `() => boolean` | | `useSession` | `() => AuthState` | | `useSignIn` | `() => MutationHandle` | | `useSignUp` | `() => MutationHandle` | | `useSignOut` | `() => MutationHandle` | | `useRefreshToken` | `() => MutationHandle` | | `useChangePassword` | `() => MutationHandle<{ currentPassword: string; newPassword: string }, void>` | | `usePasswordReset` | `() => { request: MutationHandle<{ email: string }, void>; confirm: MutationHandle<{ token: string; newPassword: string }, void> }` | | `useRegisterPasskey` | `() => MutationHandle<{ userId: string; friendlyName?: string }, RegisteredPasskey>` | | `useAuthenticateWithPasskey` | `() => MutationHandle<{ userHint?: string }, void>` | | `usePasskeyAutofill` | `(opts: UsePasskeyAutofillOptions) => { ready: boolean; error: SuaveplanError \| null; abort: () => void }` | | `useOAuthSignIn` | `() => MutationHandle<{ provider: string; mode: "popup" \| "redirect"; scopes?: string[] }, void>` | | `useEnrollTotp` | `() => { begin: MutationHandle; finish: MutationHandle<{ code: string }, { recoveryCodes: string[] }> }` | | `useVerifyTotp` | `() => MutationHandle<{ code: string }, void>` | | `useEnrollWebauthnSecondFactor` | `() => MutationHandle<{ userId: string; selector: "platform" \| "cross-platform" }, RegisteredPasskey>` | | `useRecoveryCodes` | `() => { generate: MutationHandle; verify: MutationHandle<{ code: string }, void> }` | | `useEmailVerification` | `() => { request: MutationHandle; confirm: MutationHandle<{ token: string }, void> }` | | `usePhoneVerification` | `() => { request: MutationHandle<{ phone: string }, void>; confirm: MutationHandle<{ code: string }, void> }` | | `usePermission` | `(name: string) => boolean` | | `useAuthGuard` | `() => { status: AuthState["status"]; redirectTo?: string }` | `MutationHandle` = `{ mutate(arg: TArg): Promise; status: "idle"\|"pending"\|"success"\|"error"; error: SuaveplanError \| null; data: TOut \| null; reset(): void }`. Stable identity across renders. ## Component prop tables (canonical) | Component | Required props | Optional props | |---|---|---| | `` | `backend: AuthBackend`, `children` | `storage?: StorageLike`, `broadcastChannelFactory?`, `idle?: IdleConfig`, `clock?: Clock`, `logger?: LoggerLike`, `resolveMessage?: MessageResolver`, `permissions?: PermissionResolver`, `refreshSkewMs?` | | `` | `children` | `fallback?: ReactNode`, `onUnauthenticated?: () => void` | | `` | `permission: string`, `children` | `fallback?: ReactNode` | | `` | `name: string`, `value: string`, `onChange` | `showStrength?: boolean`, `hibpClient?: HibpClient`, `autoComplete?`, `id?`, `label?: string`, `describedById?` | | `` | `password: string` | `userInputs?: string[]`, `minBits?: number` | | `` | `password: string`, `client: HibpClient` | `debounceMs?: number` | | `` | `onPick: (password: string) => void` | `mode?: "passphrase" \| "chars" \| "pin"`, `length?: number`, `wordlist?: Wordlist` | | `` | `rpId: string`, `challenge: Uint8Array`, `onCredential` | `inputProps?`, `onError?`, `signal?`, `allowCredentials?`, `userVerification?`, `timeout?`, `expectedOrigin?`, `expectedRpIdHash?` | | `` | — | `onRevoked?: (credentialId: string) => void` | | `` | `provider: string` | `mode?: "popup" \| "redirect"`, `scopes?: string[]`, `iconSlot?: ReactNode`, `labelKey?: string` | | `` | `renderQrCode: (uri: string) => ReactNode` | `onComplete?: (recoveryCodes: string[]) => void` | | `` | `codes: string[]` | `onAcknowledged?: () => void`, `downloadFileName?` | | `` | — | `cooldownMs?` | | `` | — | — | | `` | `children` | `fallback?: (err: SuaveplanError) => ReactNode` | Every prop interface is named (CLAUDE.md rule 11 — no inline shapes in signatures). ## SSR strategy - `is-ssr.ts` exposes `isServer = typeof window === "undefined"`. Every hook calls it before any DOM access. - `` initialises with `{ status: "loading", user: null, ... }` on first render. The persisted-session load runs inside `useEffect`, deterministic across server and client. - All `BroadcastChannel`, `addEventListener`, and `navigator.credentials.get` calls live in `useEffect` bodies. The hook return values are stable references during SSR. - `usePasskeyAutofill` short-circuits to `{ ready: false, error: null, abort: noop }` on the server (re-uses the autofill design from the superseded change). - Storage adapters export pure-function APIs; the `localStorage` / `sessionStorage` variants throw `REACT_AUTH.SSR_NO_OP` (caught and reported) if invoked on the server — but provider wires them inside an effect, so this only matters for misuse. ## Session-sync model - A single `BroadcastChannel("@suaveplan/react-auth")` per provider instance (factory-injected for tests + SSR). - Channel messages are typed `BroadcastEnvelope = { kind: "signed-in" | "signed-out" | "session-refreshed" | "forced-logout"; payload?: ... }`. - On `signed-in` / `session-refreshed` received from another tab: provider hydrates state from `StorageLike` (single source of truth). - On `signed-out` / `forced-logout` received: provider clears state immediately, fires the typed-error callback, and re-renders. No backend call is issued (the originating tab already did that). - Auto-refresh: `auto-refresh.ts` schedules a `setTimeout` against `refreshAt`; cancels on every state update. On success, broadcasts `session-refreshed`. - Idle timer: listens for `pointerdown`, `keydown`, `visibilitychange`, debounced to ≤ 1 event / 250 ms. Fires `onWarn` at `ttlMs - warnBeforeMs`, then `onTimeout` (default: `forceLogout("idle")`). Listeners attached in an effect; removed on unmount. ## OAuth flow ### Popup mode 1. `useOAuthSignIn().mutate({ provider, mode: "popup", scopes })`. 2. Hook generates `state` + PKCE `code_verifier` via `@suaveplan/oauth-client`'s `generatePkcePair`, persists `{ state, codeVerifier, provider }` to `sessionStorage` keyed by state. 3. Opens centred popup to `buildAuthorizationUrl(provider, ...)` from the provider registry. 4. Listens on `window.message` for `{ type: "@suaveplan/react-auth/oauth-callback", state, code }` from `same-origin` only (origin check enforced). 5. Looks up persisted PKCE state, calls `backend.oauthExchange({ code, state, provider, codeVerifier })`, persists session, broadcasts `signed-in`, closes the popup. 6. Errors: popup blocked → `OAUTH_POPUP_BLOCKED`; state mismatch → `OAUTH_STATE_MISMATCH`; exchange failure → `OAUTH_CALLBACK_FAILED`. ### Redirect mode 1. Same PKCE setup; persists return state to `sessionStorage`. 2. Calls `window.location.assign(authorizationUrl)`. 3. The callback page mounts `handleOAuthCallback()` which performs the same exchange + persist; navigates to the originally-requested URL (or `/`). ### `handleOAuthCallback()` Called from the consumer's OAuth-callback route component. Reads `code` + `state` from `window.location.search`, picks up persisted PKCE state, runs the exchange, posts a `postMessage` back to `window.opener` (popup mode) or persists session + navigates (redirect mode). Idempotent on remount via a one-shot guard keyed by `state`. ## Error code namespace `REACT_AUTH` registry is single-source-of-truth at `src/errors/errors.ts`. Every translation preserves the underlying `cause`. Every code is reachable from at least one production call site (verified by `errors.test.ts` exhaustive enumeration). Categories: - Provider: `CONTEXT_MISSING`, `INVALID_OPTIONS`, `SSR_NO_OP`. - Auth lifecycle: `UNAUTHENTICATED`, `SIGN_IN_FAILED`, `SIGN_UP_FAILED`, `SIGN_OUT_FAILED`, `REFRESH_FAILED`, `BACKEND_ERROR`. - Passwords: `PASSWORD_TOO_WEAK`, `PASSWORD_PWNED`, `PASSWORD_RESET_FAILED`, `CHANGE_PASSWORD_FAILED`. - Passkeys: `PASSKEY_REGISTER_FAILED`, `PASSKEY_AUTHENTICATE_FAILED`, `PASSKEY_AUTOFILL_UNAVAILABLE`, `PASSKEY_AUTOFILL_FAILED`, `PASSKEY_AUTOFILL_ABORTED`. - OAuth: `OAUTH_POPUP_BLOCKED`, `OAUTH_STATE_MISMATCH`, `OAUTH_CALLBACK_FAILED`. - MFA: `MFA_REQUIRED`, `MFA_VERIFY_FAILED`, `TOTP_ENROLL_FAILED`, `RECOVERY_CODE_INVALID`. - Verification: `EMAIL_VERIFY_FAILED`, `PHONE_VERIFY_FAILED`. - Session: `IDLE_TIMEOUT`, `FORCED_LOGOUT`. ## i18n contract - Catalogues at `src/i18n/{en-gb,cy-gb,zh-cn}/index.ts` (one folder per locale; one `.md` per file). - `parity.test.ts` walks `en-GB` keys and asserts the same key set exists on every other locale — fails CI on drift. - Every visible string in every component is a `react-auth..` lookup. Tests assert no hardcoded user-facing strings remain. ## Accessibility approach - Per-component `axe-core` assertions in stories (`*.stories.tsx` includes an `axe` decorator). - Playwright `accessibility.spec.ts` runs `@axe-core/playwright` across the canonical pages assembled in `e2e/fixtures/app.tsx`. - Status changes route through `announce()` (ARIA live-region helper) so screen readers hear sign-in/out + idle warnings. - All dialogs trap focus on open and restore focus on close (uses `@suaveplan/react-ui`'s `` primitive, which already does this). ## Testing approach - Vitest + `@suaveplan/testing-dom` for hooks & components. Runner imports via `@suaveplan/testing/runner` only (rule 11). No `vi.mock` (rule 2) — backends and adapters are injected. - `AuthBackend` fixtures live at `src/__fixtures__/in-memory-backend.ts` (real in-memory implementation that exercises every method; no mocks). - Playwright E2E hits a Docker-mounted stub backend (web app under `e2e/fixtures/app.tsx`). - Coverage: 100/100/100/100 Istanbul (rule 7). ## Decisions - **Transport-agnostic.** `AuthBackend` is an interface, never a fetch wrapper. Consumers decide REST / GraphQL / tRPC / Better-Auth / SDK. - **No global state.** Multiple providers (multi-tenant) coexist; each owns its own context + storage namespace. - **Browser-tier only.** Server-side session storage lives in `@suaveplan/server-session` (out of scope). - **Composable, not page-shaped.** Upstream's ``/`` are not re-shipped — they belong to the consuming app. We ship the reusable pieces. - **WebAuthn primitives stay in `@suaveplan/webauthn`.** This package only wraps them in React lifecycle. - **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes; `WEBAUTHN.*` / `OAUTH_CLIENT.*` etc. preserved on `cause`. - **SSR-safe by default.** No hook touches `window` at module load. Provider hydrates in `useEffect`. - **Accessibility is non-optional.** Every component must pass `axe` in stories and the E2E sweep — not a separate "a11y pass" later.