Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
# 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.