283 lines
17 KiB
Markdown
283 lines
17 KiB
Markdown
# 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
|
|
|
|
```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<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)
|
|
|
|
```ts
|
|
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.
|