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.tsexposesisServer = 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 insideuseEffect, deterministic across server and client.- All
BroadcastChannel,addEventListener, andnavigator.credentials.getcalls live inuseEffectbodies. The hook return values are stable references during SSR. usePasskeyAutofillshort-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/sessionStoragevariants throwREACT_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-refreshedreceived from another tab: provider hydrates state fromStorageLike(single source of truth). - On
signed-out/forced-logoutreceived: 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.tsschedules asetTimeoutagainstrefreshAt; cancels on every state update. On success, broadcastssession-refreshed. - Idle timer: listens for
pointerdown,keydown,visibilitychange, debounced to ≤ 1 event / 250 ms. FiresonWarnatttlMs - warnBeforeMs, thenonTimeout(default:forceLogout("idle")). Listeners attached in an effect; removed on unmount.
OAuth flow
Popup mode
useOAuthSignIn().mutate({ provider, mode: "popup", scopes }).- Hook generates
state+ PKCEcode_verifiervia@suaveplan/oauth-client'sgeneratePkcePair, persists{ state, codeVerifier, provider }tosessionStoragekeyed by state. - Opens centred popup to
buildAuthorizationUrl(provider, ...)from the provider registry. - Listens on
window.messagefor{ type: "@suaveplan/react-auth/oauth-callback", state, code }fromsame-originonly (origin check enforced). - Looks up persisted PKCE state, calls
backend.oauthExchange({ code, state, provider, codeVerifier }), persists session, broadcastssigned-in, closes the popup. - Errors: popup blocked →
OAUTH_POPUP_BLOCKED; state mismatch →OAUTH_STATE_MISMATCH; exchange failure →OAUTH_CALLBACK_FAILED.
Redirect mode
- Same PKCE setup; persists return state to
sessionStorage. - Calls
window.location.assign(authorizationUrl). - 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.mdper file). parity.test.tswalksen-GBkeys 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-coreassertions in stories (*.stories.tsxincludes anaxedecorator). - Playwright
accessibility.spec.tsruns@axe-core/playwrightacross the canonical pages assembled ine2e/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-domfor hooks & components. Runner imports via@suaveplan/testing/runneronly (rule 11). Novi.mock(rule 2) — backends and adapters are injected. AuthBackendfixtures live atsrc/__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.
AuthBackendis 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 oncause. - SSR-safe by default. No hook touches
windowat module load. Provider hydrates inuseEffect. - Accessibility is non-optional. Every component must pass
axein stories and the E2E sweep — not a separate "a11y pass" later.