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

17 KiB

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 <AuthProvider> and tree-shakeable via subpath exports.

File layout

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

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<void>;
    signUp(opts: SignUpOptions): Promise<void>;
    signOut(opts?: SignOutOptions): Promise<void>;
    refresh(): Promise<void>;
    forceLogout(reason: string): void;
    backend: AuthBackend;
    resolveMessage: MessageResolver;
    permissions: PermissionResolver;
}

AuthBackend interface (caller-implemented)

interface AuthBackend {
    passwordSignIn(p: { email: string; password: string }): Promise<SignInResult>;
    srpSignIn(p: SrpHandshakeIn): Promise<SignInResult>;
    signUp(p: SignUpRequest): Promise<SignUpResult>;
    requestPasswordReset(p: { email: string }): Promise<void>;
    confirmPasswordReset(p: { token: string; newPassword: string }): Promise<void>;
    changePassword(p: { currentPassword: string; newPassword: string }): Promise<void>;
    requestEmailVerification(): Promise<void>;
    confirmEmailVerification(p: { token: string }): Promise<void>;
    requestPhoneVerification(p: { phone: string }): Promise<void>;
    confirmPhoneVerification(p: { code: string }): Promise<void>;
    oauthExchange(p: { code: string; state: string; provider: string; codeVerifier: string }): Promise<TokenSet>;
    oauthRefresh(p: { refreshToken: string }): Promise<TokenSet>;
    oauthRevoke(p: { token: string }): Promise<void>;
    passkeyRegisterOptions(p: { userId: string }): Promise<WebAuthnRegisterOptions>;
    passkeyRegisterFinish(p: { credential: RegisterResult }): Promise<RegisteredPasskey>;
    passkeyAuthenticateOptions(p: { userHint?: string; mediation: "required" | "conditional" }): Promise<WebAuthnAuthenticateOptions>;
    passkeyAuthenticateFinish(p: { assertion: AuthenticateResult }): Promise<SignInResult>;
    listPasskeys(): Promise<RegisteredPasskey[]>;
    revokePasskey(p: { credentialId: string }): Promise<void>;
    totpEnrollBegin(): Promise<{ secret: string; provisioningUri: string }>;
    totpEnrollFinish(p: { code: string }): Promise<{ recoveryCodes: string[] }>;
    totpVerify(p: { code: string }): Promise<SignInResult>;
    recoveryCodesGenerate(): Promise<{ codes: string[] }>;
    recoveryCodesVerify(p: { code: string }): Promise<SignInResult>;
    sessionRefresh(): Promise<TokenSet>;
    sessionRevoke(): Promise<void>;
}

Hook signatures (summary table)

Hook Signature
useAuth () => AuthContextValue
useUser () => AuthUser | null
useIsAuthenticated () => boolean
useSession () => AuthState
useSignIn () => MutationHandle<SignInOptions, void>
useSignUp () => MutationHandle<SignUpOptions, void>
useSignOut () => MutationHandle<SignOutOptions | void, void>
useRefreshToken () => MutationHandle<void, void>
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<void, { secret: string; provisioningUri: string }>; finish: MutationHandle<{ code: string }, { recoveryCodes: string[] }> }
useVerifyTotp () => MutationHandle<{ code: string }, void>
useEnrollWebauthnSecondFactor () => MutationHandle<{ userId: string; selector: "platform" | "cross-platform" }, RegisteredPasskey>
useRecoveryCodes () => { generate: MutationHandle<void, { codes: string[] }>; verify: MutationHandle<{ code: string }, void> }
useEmailVerification () => { request: MutationHandle<void, void>; 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<TArg, TOut> = { mutate(arg: TArg): Promise<TOut>; 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
<AuthProvider> backend: AuthBackend, children storage?: StorageLike, broadcastChannelFactory?, idle?: IdleConfig, clock?: Clock, logger?: LoggerLike, resolveMessage?: MessageResolver, permissions?: PermissionResolver, refreshSkewMs?
<AuthGuard> children fallback?: ReactNode, onUnauthenticated?: () => void
<PermissionGate> permission: string, children fallback?: ReactNode
<PasswordInput> name: string, value: string, onChange showStrength?: boolean, hibpClient?: HibpClient, autoComplete?, id?, label?: string, describedById?
<PasswordStrengthMeter> password: string userInputs?: string[], minBits?: number
<HibpPwnedIndicator> password: string, client: HibpClient debounceMs?: number
<PasswordGeneratorButton> onPick: (password: string) => void mode?: "passphrase" | "chars" | "pin", length?: number, wordlist?: Wordlist
<PasskeyAutofillField> rpId: string, challenge: Uint8Array, onCredential inputProps?, onError?, signal?, allowCredentials?, userVerification?, timeout?, expectedOrigin?, expectedRpIdHash?
<PasskeyList> — onRevoked?: (credentialId: string) => void
<OAuthProviderButton> provider: string mode?: "popup" | "redirect", scopes?: string[], iconSlot?: ReactNode, labelKey?: string
<TotpEnrollmentPanel> renderQrCode: (uri: string) => ReactNode onComplete?: (recoveryCodes: string[]) => void
<RecoveryCodesPanel> codes: string[] onAcknowledged?: () => void, downloadFileName?
<EmailVerificationView> — cooldownMs?
<PhoneVerificationView> — —
<AuthErrorBoundary> 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.
  • <AuthProvider> 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.<group>.<key> 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 <Dialog> 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 <LoginPage>/<ProfilePage> 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.