Files
suaveplan/openspec/changes/complete-react-auth/specs/react-auth/spec.md
2026-05-27 23:29:53 +01:00

43 KiB

Capability Spec Delta: react-auth (complete-react-auth)

Package: @suaveplan/react-auth Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time naming planned test files. No exceptions. (See openspec/AGENTS.md §0c.)

ADDED Requirements

Requirement: AuthProvider establishes context, hydrates persisted session, and is SSR-safe

The package SHALL export <AuthProvider> that accepts AuthBackend, optional StorageLike, optional BroadcastChannel factory, optional IdleConfig, optional Clock, optional LoggerLike, and optional MessageResolver. The provider SHALL render with a deterministic { status: "loading", user: null } state on first paint (including SSR), then hydrate persisted session inside useEffect on the client. The provider SHALL never access window, document, navigator, localStorage, sessionStorage, or BroadcastChannel at module load or during server render.

Scenario: server render contains zero DOM access

  • WHEN <AuthProvider> is rendered in a server-side environment (typeof window === "undefined")
  • THEN no window/document/navigator/localStorage/sessionStorage/BroadcastChannel access occurs
  • AND the rendered children see state.status === "loading"

Scenario: persisted session is loaded on client mount

  • WHEN the provider mounts on the client and the injected StorageLike contains a valid persisted session
  • THEN the provider hydrates state to { status: "authenticated", user, accessToken, expiresAt, refreshAt } inside useEffect
  • AND consumers observe the hydrated state on the second render

Scenario: useAuthContext throws CONTEXT_MISSING outside provider

  • WHEN useAuthContext() is called outside any <AuthProvider>
  • THEN a SuaveplanError with code === "REACT_AUTH.CONTEXT_MISSING" is thrown synchronously

Verified by: src/auth-provider/auth-provider.test.tsx::"renders deterministic loading state on SSR" AND src/auth-provider/auth-provider.test.tsx::"hydrates persisted session inside useEffect on client mount" AND src/auth-provider/use-auth-context.test.ts::"throws CONTEXT_MISSING when used outside provider"


Requirement: State read hooks expose context selectors

The package SHALL export useAuth, useUser, useIsAuthenticated, and useSession hooks. Each SHALL read from <AuthProvider> context, return stable references across renders when the underlying slice is unchanged, and never trigger re-renders for unrelated state changes.

Scenario: useUser returns null when unauthenticated

  • WHEN the provider state is { status: "unauthenticated" }
  • THEN useUser() returns null
  • AND useIsAuthenticated() returns false

Scenario: useSession returns the full state slice

  • WHEN the provider state is { status: "authenticated", user, accessToken, expiresAt, refreshAt }
  • THEN useSession() returns the same shape
  • AND repeated calls with unchanged state return referentially-equal objects

Verified by: src/hooks/use-user/use-user.test.ts::"returns null when unauthenticated" AND src/hooks/use-is-authenticated/use-is-authenticated.test.ts::"returns false when unauthenticated" AND src/hooks/use-session/use-session.test.ts::"returns the full state slice with stable identity" AND src/hooks/use-auth/use-auth.test.ts::"exposes mutation methods"


Requirement: Sign-in / sign-up / sign-out / refresh hooks wrap the backend with typed mutation handles

The package SHALL export useSignIn, useSignUp, useSignOut, and useRefreshToken. Each SHALL return a MutationHandle with { mutate, status, error, data, reset } whose identity is stable across renders. Successful mutations SHALL update provider state and broadcast signed-in / signed-out / session-refreshed via the configured BroadcastChannel. Failures SHALL translate AuthBackend errors into REACT_AUTH.SIGN_IN_FAILED / SIGN_UP_FAILED / SIGN_OUT_FAILED / REFRESH_FAILED respectively, preserving the underlying error on cause.

Scenario: successful sign-in updates context and broadcasts

  • WHEN useSignIn().mutate({ email, password }) resolves
  • THEN provider state transitions to { status: "authenticated", user, accessToken, expiresAt, refreshAt }
  • AND a signed-in envelope is posted on the broadcast channel

Scenario: backend failure surfaces SIGN_IN_FAILED with cause preserved

  • WHEN AuthBackend.passwordSignIn rejects with an error
  • THEN useSignIn().error is a SuaveplanError with code === "REACT_AUTH.SIGN_IN_FAILED"
  • AND the original error is preserved on error.cause

Scenario: sign-out broadcasts and clears storage

  • WHEN useSignOut().mutate() resolves
  • THEN provider state transitions to { status: "unauthenticated", user: null }
  • AND the injected StorageLike no longer contains the persisted session
  • AND a signed-out envelope is posted on the broadcast channel

Scenario: refresh failure transitions to unauthenticated

  • WHEN useRefreshToken().mutate() rejects
  • THEN the provider state transitions to { status: "unauthenticated", error: REACT_AUTH.REFRESH_FAILED }

Verified by: src/hooks/use-sign-in/use-sign-in.test.ts::"updates context and broadcasts on success" AND src/hooks/use-sign-in/use-sign-in.test.ts::"translates backend failure to SIGN_IN_FAILED with cause" AND src/hooks/use-sign-up/use-sign-up.test.ts::"signs the user in on success" AND src/hooks/use-sign-out/use-sign-out.test.ts::"clears storage and broadcasts signed-out" AND src/hooks/use-refresh-token/use-refresh-token.test.ts::"transitions to unauthenticated on refresh failure"


Requirement: Password hooks cover change, request-reset, and confirm-reset flows

The package SHALL export useChangePassword and usePasswordReset. useChangePassword SHALL return a MutationHandle<{ currentPassword: string; newPassword: string }, void>. usePasswordReset SHALL return { request, confirm }, each a MutationHandle. New-password values SHALL be rejected synchronously with REACT_AUTH.PASSWORD_TOO_WEAK if password-strength.estimate(...).score < minBits (where minBits defaults to 50 bits-equivalent) and SHALL be rejected with REACT_AUTH.PASSWORD_PWNED if an injected HibpClient is supplied and the password appears in HIBP.

Scenario: weak password is rejected before backend call

  • WHEN useChangePassword().mutate({ currentPassword, newPassword: "abc" }) is invoked
  • THEN the mutation rejects with REACT_AUTH.PASSWORD_TOO_WEAK
  • AND AuthBackend.changePassword is not called

Scenario: pwned password is rejected before backend call

  • WHEN useChangePassword({ hibpClient }).mutate({ currentPassword, newPassword }) is invoked AND HIBP reports the password as pwned
  • THEN the mutation rejects with REACT_AUTH.PASSWORD_PWNED

Scenario: request-reset and confirm-reset round-trip

  • WHEN usePasswordReset().request.mutate({ email }) resolves and the user later calls confirm.mutate({ token, newPassword })
  • THEN the backend is called with the supplied token and a strength-validated password
  • AND provider state remains unchanged (reset flow does not authenticate)

Verified by: src/hooks/use-change-password/use-change-password.test.ts::"rejects weak passwords before calling backend" AND src/hooks/use-change-password/use-change-password.test.ts::"rejects HIBP-pwned passwords when hibpClient is supplied" AND src/hooks/use-password-reset/use-password-reset.test.ts::"request and confirm hit the backend in sequence"


Requirement: PasswordInput composes strength + HIBP slots with accessible markup

The package SHALL export <PasswordInput> rendering an <input type="password"> with optional show/hide toggle, optional <PasswordStrengthMeter> slot, and optional <HibpPwnedIndicator> slot. The component SHALL associate the input with its label via htmlFor / id, link help/error text via aria-describedby, set aria-invalid on error, and meet WCAG 2.1 AA contrast ratios in the bundled stories.

Scenario: input is associated with label and described-by id

  • WHEN <PasswordInput name="pwd" id="pwd" label="Password" describedById="pwd-help" /> is rendered
  • THEN the rendered <label> carries for="pwd"
  • AND the rendered <input> carries aria-describedby="pwd-help"

Scenario: show/hide toggle flips input type and announces state

  • WHEN the user clicks the show/hide toggle
  • THEN <input type> flips between "password" and "text"
  • AND the toggle button's aria-pressed state reflects the visibility

Scenario: axe-core finds zero serious violations

  • WHEN <PasswordInput> is rendered in the canonical story
  • THEN axe.run reports zero serious or critical violations

Verified by: src/components/password-input/password-input.test.tsx::"associates label with input via htmlFor" AND src/components/password-input/password-input.test.tsx::"toggle flips type and aria-pressed" AND e2e/accessibility.spec.ts::"PasswordInput passes axe with zero serious violations"


Requirement: PasswordStrengthMeter wraps password-strength.assessReactive with cancellation

The package SHALL export <PasswordStrengthMeter> that wraps @suaveplan/password-strength.assessReactive. On every password prop change the component SHALL cancel any in-flight assessment via AbortController. On unmount the component SHALL abort the current assessment.

Scenario: rapid prop changes do not leak assessments

  • WHEN the password prop changes 100 times within a single tick
  • THEN at most one assessReactive Promise is still pending after settle
  • AND no unhandled rejection is logged

Scenario: unmount cancels the in-flight assessment

  • WHEN the component unmounts while an assessReactive Promise is pending
  • THEN the underlying AbortController aborts
  • AND the Promise rejects with AbortError (not surfaced to the consumer)

Verified by: src/components/password-strength-meter/password-strength-meter.test.tsx::"cancels stale assessments when password changes" AND src/components/password-strength-meter/password-strength-meter.test.tsx::"aborts on unmount"


Requirement: HibpPwnedIndicator debounces, SSR-no-ops, and surfaces pwned state

The package SHALL export <HibpPwnedIndicator> that wraps an injected HibpClient (per @suaveplan/hibp), debounces input changes by at least 500 ms, never performs a network request during SSR or when password is empty, and surfaces the pwned/clean result via accessible status text in an aria-live="polite" region.

Scenario: SSR render performs no network access

  • WHEN the component is rendered server-side
  • THEN no method on the injected HibpClient is called

Scenario: rapid keystrokes coalesce to a single request

  • WHEN the password prop changes 10 times in under 500 ms
  • THEN the HibpClient.checkPassword method is called at most once after debounce

Scenario: pwned result is announced via aria-live region

  • WHEN the client reports the password as pwned
  • THEN an element with aria-live="polite" contains the localised pwned message

Verified by: src/components/hibp-pwned-indicator/hibp-pwned-indicator.test.tsx::"performs no HIBP call during SSR render" AND src/components/hibp-pwned-indicator/hibp-pwned-indicator.test.tsx::"debounces rapid keystrokes to a single request" AND src/components/hibp-pwned-indicator/hibp-pwned-indicator.test.tsx::"announces pwned result via aria-live region"


Requirement: PasswordGeneratorButton emits passphrase / chars / pin per @suaveplan/password-generator

The package SHALL export <PasswordGeneratorButton> that, on activation, opens a popover with a generated password from @suaveplan/password-generator. The mode SHALL be "passphrase" (default), "chars", or "pin". On accept the component SHALL invoke the consumer's onPick(password) callback. The popover SHALL be keyboard-accessible (Escape closes, focus traps inside).

Scenario: default mode generates a passphrase

  • WHEN <PasswordGeneratorButton onPick={...} /> is opened and the user accepts the suggestion
  • THEN onPick is invoked once with a passphrase generated via @suaveplan/password-generator

Scenario: Escape closes and returns focus to trigger

  • WHEN the popover is open and the user presses Escape
  • THEN the popover closes
  • AND focus returns to the trigger button

Verified by: src/components/password-generator-button/password-generator-button.test.tsx::"emits a passphrase on accept" AND src/components/password-generator-button/password-generator-button.test.tsx::"escape closes popover and restores focus"


Requirement: useRegisterPasskey wraps webauthn.register and persists the result

The package SHALL export useRegisterPasskey returning a MutationHandle<{ userId: string; friendlyName?: string }, RegisteredPasskey>. The mutation SHALL call AuthBackend.passkeyRegisterOptions, invoke webauthn.register from @suaveplan/webauthn, then call AuthBackend.passkeyRegisterFinish. Failures SHALL be translated to REACT_AUTH.PASSKEY_REGISTER_FAILED with the underlying WEBAUTHN.* cause preserved.

Scenario: full register handshake completes successfully

  • WHEN the mutation runs end-to-end
  • THEN passkeyRegisterOptions, webauthn.register, and passkeyRegisterFinish are called in order
  • AND the resolved value is the RegisteredPasskey from the backend

Scenario: webauthn cancellation surfaces PASSKEY_REGISTER_FAILED

  • WHEN the user cancels the platform passkey UI (yielding WEBAUTHN.ABORTED)
  • THEN the mutation rejects with REACT_AUTH.PASSKEY_REGISTER_FAILED
  • AND error.cause.code === "WEBAUTHN.ABORTED"

Verified by: src/hooks/use-register-passkey/use-register-passkey.test.ts::"runs options → register → finish in order" AND src/hooks/use-register-passkey/use-register-passkey.test.ts::"translates WEBAUTHN.ABORTED to PASSKEY_REGISTER_FAILED with cause"


Requirement: useAuthenticateWithPasskey runs modal mediation and updates context

The package SHALL export useAuthenticateWithPasskey returning a MutationHandle<{ userHint?: string }, void> that calls AuthBackend.passkeyAuthenticateOptions({ mediation: "required" }), invokes webauthn.authenticate, then AuthBackend.passkeyAuthenticateFinish. On success the provider state SHALL transition to authenticated.

Scenario: successful modal passkey sign-in updates state

  • WHEN the mutation resolves
  • THEN provider state transitions to { status: "authenticated", user, accessToken, … }

Scenario: backend failure surfaces PASSKEY_AUTHENTICATE_FAILED

  • WHEN passkeyAuthenticateFinish rejects
  • THEN the mutation rejects with REACT_AUTH.PASSKEY_AUTHENTICATE_FAILED with cause preserved

Verified by: src/hooks/use-authenticate-with-passkey/use-authenticate-with-passkey.test.ts::"transitions to authenticated on success" AND src/hooks/use-authenticate-with-passkey/use-authenticate-with-passkey.test.ts::"translates backend failure to PASSKEY_AUTHENTICATE_FAILED"


Requirement: usePasskeyAutofill dispatches conditional WebAuthn with SSR-safe lifecycle

The package SHALL export usePasskeyAutofill(options) that, on the first client mount, feature-detects PublicKeyCredential.isConditionalMediationAvailable() via @suaveplan/webauthn and dispatches authenticate({ mediation: "conditional" }) with the caller-supplied rpId, challenge, allowCredentials, userVerification, timeout, expectedOrigin, and expectedRpIdHash. The hook SHALL own one AbortController per effect run composed with options.signal, abort on unmount / options.signal / returned abort(), perform zero work during SSR, return { ready: false, error: null, abort } on the server, and validate options via Zod, throwing REACT_AUTH.INVALID_OPTIONS synchronously on mismatch. The returned abort reference SHALL be stable across re-renders.

Scenario: conditional authenticate is invoked when supported

  • WHEN the hook mounts in a browser whose isConditionalMediationAvailable() resolves true
  • THEN webauthn.authenticate is called with mediation: "conditional" and the forwarded option fields
  • AND ready flips to true once the request is in-flight

Scenario: SSR render performs no DOM access

  • WHEN the hook is rendered with typeof window === "undefined"
  • THEN no navigator/document/PublicKeyCredential access occurs
  • AND the return is { ready: false, error: null, abort }

Scenario: missing Conditional UI surfaces PASSKEY_AUTOFILL_UNAVAILABLE

  • WHEN isConditionalMediationAvailable is missing or resolves false
  • THEN authenticate is never called and onError (if supplied) receives REACT_AUTH.PASSKEY_AUTOFILL_UNAVAILABLE

Scenario: unmount aborts the in-flight request

  • WHEN the component unmounts while the request is in-flight
  • THEN the request rejects with AbortError translated to REACT_AUTH.PASSKEY_AUTOFILL_ABORTED (cause preserved)

Scenario: returned abort reference is stable across re-renders

  • WHEN the parent re-renders with unchanged options
  • THEN Object.is(prevReturn.abort, nextReturn.abort) === true

Scenario: invalid options throw INVALID_OPTIONS synchronously

  • WHEN the caller passes rpId: "" or a non-Uint8Array challenge
  • THEN the hook throws REACT_AUTH.INVALID_OPTIONS during render
  • AND no authenticate call is dispatched

Verified by: src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"dispatches authenticate with mediation conditional when available" AND src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"performs no work during SSR render" AND src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"reports PASSKEY_AUTOFILL_UNAVAILABLE when conditional UI missing" AND src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"aborts the in-flight request on unmount" AND src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort reference is stable across re-renders" AND src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS for empty rpId"


Requirement: PasskeyAutofillField renders the autocomplete input and blur-cancels

The package SHALL export <PasskeyAutofillField> rendering an <input autocomplete="username webauthn"> wired to usePasskeyAutofill. The component SHALL call the hook's abort() on the input's blur event when no credential has yet been delivered. After onCredential has fired, subsequent blur events SHALL NOT trigger abort(). Consumer-supplied inputProps SHALL be forwarded to the underlying <input> (except autocomplete, which is fixed).

Scenario: input has autocomplete="username webauthn"

  • WHEN <PasskeyAutofillField rpId="example.test" challenge={c} onCredential={...} /> is rendered
  • THEN the rendered <input> has autocomplete="username webauthn"

Scenario: blur cancels the in-flight request

  • WHEN the user blurs the input before any credential is delivered
  • THEN abort() is invoked and the in-flight request rejects with REACT_AUTH.PASSKEY_AUTOFILL_ABORTED

Scenario: blur after credential delivery is a no-op

  • WHEN onCredential has already fired
  • THEN a subsequent blur does NOT invoke abort()

Verified by: src/components/passkey-autofill-field/passkey-autofill-field.test.tsx::"renders input with autocomplete username webauthn" AND src/components/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur cancels in-flight request" AND src/components/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur after credential delivery is a no-op"


Requirement: PasskeyList lists registered authenticators and revokes per row

The package SHALL export <PasskeyList> that reads from AuthBackend.listPasskeys(), renders one row per credential with friendly name + last-used timestamp + revoke button, and on revoke confirmation calls AuthBackend.revokePasskey({ credentialId }). The revoke confirmation dialog SHALL trap focus and restore focus on close.

Scenario: list renders one row per registered passkey

  • WHEN the backend returns three registered passkeys
  • THEN the component renders exactly three rows, each carrying the friendly name and a revoke button

Scenario: revoke flow calls backend after confirmation

  • WHEN the user clicks revoke and confirms
  • THEN AuthBackend.revokePasskey is called with the row's credentialId
  • AND the row is removed from the list after success

Verified by: src/components/passkey-list/passkey-list.test.tsx::"renders one row per registered passkey" AND src/components/passkey-list/passkey-list.test.tsx::"revokes the credential after confirmation"


Requirement: useOAuthSignIn runs popup and redirect modes with PKCE

The package SHALL export useOAuthSignIn returning a MutationHandle<{ provider: string; mode: "popup" | "redirect"; scopes?: string[] }, void>. The hook SHALL generate state and a PKCE pair via @suaveplan/oauth-client.generatePkcePair, persist { state, codeVerifier, provider } to a return-state store (sessionStorage by default), and either open a centred popup (popup mode) or call window.location.assign(authorizationUrl) (redirect mode). On popup-mode receipt of postMessage from a same-origin source, the hook SHALL call AuthBackend.oauthExchange, persist the resulting session, broadcast signed-in, and close the popup.

Scenario: popup-blocked surfaces OAUTH_POPUP_BLOCKED

  • WHEN window.open returns null
  • THEN the mutation rejects with REACT_AUTH.OAUTH_POPUP_BLOCKED

Scenario: state mismatch surfaces OAUTH_STATE_MISMATCH

  • WHEN the callback postMessage carries a state that does not match the persisted PKCE state
  • THEN the mutation rejects with REACT_AUTH.OAUTH_STATE_MISMATCH
  • AND no oauthExchange call is made

Scenario: cross-origin postMessage is ignored

  • WHEN the popup receives a postMessage from a different origin than the parent
  • THEN the message is ignored and the mutation remains pending

Scenario: redirect mode persists return-state and navigates

  • WHEN mutate({ provider, mode: "redirect" }) is called
  • THEN the PKCE state is persisted to the return-state store
  • AND window.location.assign is called with the authorization URL

Verified by: src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"surfaces OAUTH_POPUP_BLOCKED when window.open returns null" AND src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"surfaces OAUTH_STATE_MISMATCH on state mismatch" AND src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"ignores cross-origin postMessage" AND src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"redirect mode persists state and navigates"


Requirement: handleOAuthCallback parses, validates, exchanges, and is idempotent

The package SHALL export handleOAuthCallback() that reads code and state from window.location.search, validates state against the persisted PKCE entry, calls AuthBackend.oauthExchange, and either postMessages the result back to window.opener (popup mode) or persists the session + navigates to the originally-requested URL (redirect mode). The function SHALL be idempotent across remounts via a one-shot guard keyed by state.

Scenario: missing PKCE state surfaces OAUTH_STATE_MISMATCH

  • WHEN the persisted return-state store has no entry for the URL state parameter
  • THEN the function rejects with REACT_AUTH.OAUTH_STATE_MISMATCH

Scenario: idempotent across remounts

  • WHEN handleOAuthCallback() is called twice with the same state
  • THEN AuthBackend.oauthExchange is called at most once

Verified by: src/oauth/handle-callback.test.ts::"rejects with OAUTH_STATE_MISMATCH when state is unknown" AND src/oauth/handle-callback.test.ts::"is idempotent across remounts"


Requirement: OAuthProviderButton + OAuthProviderRegistry expose registered providers accessibly

The package SHALL export OAuthProviderRegistry with register, get, has, list, and unregister. The package SHALL export <OAuthProviderButton provider="..."> that renders a button labelled via react-auth.oauth.<provider>.label i18n key, with an icon slot, and on click invokes useOAuthSignIn.

Scenario: button is keyboard-activatable

  • WHEN the button receives focus and the user presses Enter
  • THEN useOAuthSignIn().mutate is invoked

Scenario: registry rejects duplicate registrations

  • WHEN register is called twice with the same provider name
  • THEN the second call throws a registry-error code defined in the registry module

Verified by: src/components/oauth-provider-button/oauth-provider-button.test.tsx::"invokes sign-in on Enter" AND src/oauth/provider-registry.test.ts::"rejects duplicate registrations"


Requirement: TOTP and recovery-code hooks cover full MFA lifecycle

The package SHALL export useEnrollTotp, useVerifyTotp, useEnrollWebauthnSecondFactor, and useRecoveryCodes. useEnrollTotp SHALL provide { begin, finish } mutation handles. useRecoveryCodes SHALL provide { generate, verify } mutation handles. useVerifyTotp SHALL transition provider state from mfa-required to authenticated on success. Failures SHALL be translated to TOTP_ENROLL_FAILED, MFA_VERIFY_FAILED, or RECOVERY_CODE_INVALID.

Scenario: TOTP enrollment returns secret + provisioning URI then recovery codes

  • WHEN useEnrollTotp().begin.mutate() resolves then finish.mutate({ code }) resolves
  • THEN the consumer receives { secret, provisioningUri } from begin and { recoveryCodes } from finish

Scenario: TOTP verification transitions out of mfa-required

  • WHEN provider state is mfa-required and useVerifyTotp().mutate({ code }) resolves
  • THEN provider state transitions to authenticated

Scenario: invalid recovery code rejects with RECOVERY_CODE_INVALID

  • WHEN useRecoveryCodes().verify.mutate({ code: "bad" }) rejects from the backend
  • THEN the surfaced error is REACT_AUTH.RECOVERY_CODE_INVALID

Verified by: src/hooks/use-enroll-totp/use-enroll-totp.test.ts::"begin returns secret and uri, finish returns recovery codes" AND src/hooks/use-verify-totp/use-verify-totp.test.ts::"transitions out of mfa-required on success" AND src/hooks/use-recovery-codes/use-recovery-codes.test.ts::"rejects invalid recovery codes with RECOVERY_CODE_INVALID" AND src/hooks/use-enroll-webauthn-second-factor/use-enroll-webauthn-second-factor.test.ts::"forwards authenticatorSelection to webauthn.register"


Requirement: TotpEnrollmentPanel + RecoveryCodesPanel orchestrate accessible MFA UX

The package SHALL export <TotpEnrollmentPanel> that wires useEnrollTotp end-to-end and renders a consumer-supplied QR slot. The component SHALL not display recovery codes until enrollment finalises. The package SHALL export <RecoveryCodesPanel> that lists generated codes with copy-to-clipboard, download-as-text, and an "I have saved these" acknowledgement checkbox that must be checked before dismissal.

Scenario: recovery codes are hidden until finalised

  • WHEN <TotpEnrollmentPanel> is in the begin state
  • THEN no recovery code is rendered in the DOM

Scenario: dismiss requires acknowledgement

  • WHEN the user attempts to dismiss <RecoveryCodesPanel> without checking the acknowledgement
  • THEN the dismiss is prevented and a react-auth.recovery.must-acknowledge warning is announced via aria-live

Scenario: copy and download produce the same payload

  • WHEN the user clicks copy and then download
  • THEN the clipboard contents and the downloaded text match exactly

Verified by: src/components/totp-enrollment-panel/totp-enrollment-panel.test.tsx::"hides recovery codes until finalised" AND src/components/recovery-codes-panel/recovery-codes-panel.test.tsx::"prevents dismiss without acknowledgement" AND src/components/recovery-codes-panel/recovery-codes-panel.test.tsx::"copy and download contents match"


Requirement: Email and phone verification hooks + views

The package SHALL export useEmailVerification and usePhoneVerification, each returning { request, confirm } mutation handles wired to the corresponding AuthBackend methods. The package SHALL export <EmailVerificationView> with a resend cooldown timer and <PhoneVerificationView> whose code input carries inputMode="numeric" and autoComplete="one-time-code".

Scenario: resend cooldown disables the button

  • WHEN the user clicks "resend" inside <EmailVerificationView>
  • THEN the resend button is disabled for at least cooldownMs (default 30000)
  • AND an aria-live region announces the remaining time

Scenario: phone code input is optimised for mobile autofill

  • WHEN <PhoneVerificationView> is rendered
  • THEN the code <input> carries inputMode="numeric" and autoComplete="one-time-code"

Verified by: src/components/email-verification-view/email-verification-view.test.tsx::"resend is disabled during cooldown" AND src/components/phone-verification-view/phone-verification-view.test.tsx::"code input carries one-time-code autocomplete" AND src/hooks/use-email-verification/use-email-verification.test.ts::"request and confirm hit the backend" AND src/hooks/use-phone-verification/use-phone-verification.test.ts::"request and confirm hit the backend"


Requirement: Guards — AuthGuard, PermissionGate, useAuthGuard, usePermission, withAuth

The package SHALL export <AuthGuard>, <PermissionGate>, useAuthGuard, usePermission, and withAuth(Component). <AuthGuard> SHALL render children when authenticated and otherwise render the fallback prop; when no fallback is supplied the guard SHALL throw REACT_AUTH.UNAUTHENTICATED for the nearest error boundary. <PermissionGate> SHALL consult the injected PermissionResolver; absent permission SHALL render fallback or null.

Scenario: AuthGuard renders fallback when unauthenticated

  • WHEN <AuthGuard fallback={<div data-testid="x" />}> is mounted with provider state unauthenticated
  • THEN the data-testid="x" element renders and children do not

Scenario: AuthGuard throws UNAUTHENTICATED when no fallback

  • WHEN <AuthGuard> is mounted with no fallback and provider state unauthenticated
  • THEN a SuaveplanError with code === "REACT_AUTH.UNAUTHENTICATED" is thrown during render

Scenario: PermissionGate consults injected resolver

  • WHEN <PermissionGate permission="admin"> is mounted and permissions.has("admin") returns false
  • THEN children are not rendered

Verified by: src/components/auth-guard/auth-guard.test.tsx::"renders fallback when unauthenticated" AND src/components/auth-guard/auth-guard.test.tsx::"throws UNAUTHENTICATED when no fallback supplied" AND src/components/permission-gate/permission-gate.test.tsx::"hides children when permission is absent" AND src/hooks/use-auth-guard/use-auth-guard.test.ts::"exposes status and redirectTo" AND src/hooks/use-permission/use-permission.test.ts::"returns boolean from resolver"


Requirement: AuthErrorBoundary catches REACT_AUTH errors and renders i18n-keyed fallback

The package SHALL export <AuthErrorBoundary> that catches any thrown SuaveplanError whose code starts with "REACT_AUTH." and renders a fallback(error) slot (default: an accessible alert resolving react-auth.errors.<code> via the injected MessageResolver). Non-REACT_AUTH errors SHALL be re-thrown so other boundaries handle them.

Scenario: REACT_AUTH error is caught and rendered

  • WHEN a child throws REACT_AUTH.UNAUTHENTICATED
  • THEN the boundary renders the resolved fallback message
  • AND the alert element carries role="alert"

Scenario: non-REACT_AUTH error is re-thrown

  • WHEN a child throws an unrelated Error
  • THEN the boundary re-throws so a parent boundary can handle it

Verified by: src/components/auth-error-boundary/auth-error-boundary.test.tsx::"renders i18n fallback for REACT_AUTH errors" AND src/components/auth-error-boundary/auth-error-boundary.test.tsx::"re-throws non-REACT_AUTH errors"


Requirement: Multi-tab session sync via BroadcastChannel

The provider SHALL open a BroadcastChannel("@suaveplan/react-auth") (via injected factory) on mount and SHALL post typed envelopes on every sign-in, sign-out, refresh, and forced-logout. Receivers SHALL hydrate state from StorageLike on signed-in / session-refreshed and SHALL clear state synchronously on signed-out / forced-logout without re-calling the backend.

Scenario: sign-out in tab A clears state in tab B

  • WHEN tab A signs out and emits the signed-out envelope
  • THEN tab B's provider state transitions to unauthenticated
  • AND no AuthBackend.sessionRevoke call is made in tab B

Scenario: forced-logout broadcasts to every tab

  • WHEN forceLogout("policy-violation") is invoked
  • THEN every subscribed tab transitions to unauthenticated
  • AND the reason is observable via the forced-logout envelope payload

Verified by: src/session/broadcast-channel.test.ts::"emits typed envelopes for lifecycle events" AND src/auth-provider/auth-provider.test.tsx::"receives signed-out from another tab and clears state without backend call" AND e2e/multi-tab-sync.spec.ts::"sign-out in one tab propagates to another"


Requirement: Auto-refresh fires before expiry via injected Clock

The provider SHALL schedule a refresh at expiresAt - refreshSkewMs (default 60000) using the injected Clock. The schedule SHALL be cancelled on every state update, every sign-out, and on unmount. On refresh success the provider SHALL broadcast session-refreshed. On refresh failure the provider SHALL transition to unauthenticated with REACT_AUTH.REFRESH_FAILED.

Scenario: refresh fires at refreshAt - skew

  • WHEN the clock advances past refreshAt
  • THEN AuthBackend.sessionRefresh is called exactly once

Scenario: refresh failure clears state

  • WHEN the scheduled sessionRefresh rejects
  • THEN provider state transitions to unauthenticated with REACT_AUTH.REFRESH_FAILED

Verified by: src/session/auto-refresh.test.ts::"fires sessionRefresh at refreshAt minus skew" AND src/session/auto-refresh.test.ts::"transitions to unauthenticated on refresh failure"


Requirement: Idle timeout triggers warning then forced sign-out

When IdleConfig is supplied, the provider SHALL listen for pointerdown, keydown, and visibilitychange events on document, debounce to ≤ 1 reset / 250 ms, fire onWarn at ttlMs - warnBeforeMs, and fire onTimeout (default: forceLogout("idle")) at ttlMs. Listeners SHALL be removed on unmount.

Scenario: pointer activity resets the idle counter

  • WHEN a pointerdown fires before the warning threshold
  • THEN the warning + timeout timers are reset

Scenario: warning then timeout fire in order

  • WHEN the clock advances past ttlMs - warnBeforeMs then past ttlMs
  • THEN onWarn fires first, then onTimeout
  • AND provider state transitions to unauthenticated with REACT_AUTH.IDLE_TIMEOUT

Verified by: src/session/idle-timer.test.ts::"resets timers on pointerdown" AND src/session/idle-timer.test.ts::"fires onWarn then onTimeout and transitions to unauthenticated" AND e2e/idle-timeout.spec.ts::"forces sign-out after idle ttl"


Requirement: REACT_AUTH error registry is complete and every code reachable

The package SHALL export a defineCodes("REACT_AUTH", [...]) registry whose set equals exactly: CONTEXT_MISSING, UNAUTHENTICATED, BACKEND_ERROR, SIGN_IN_FAILED, SIGN_UP_FAILED, SIGN_OUT_FAILED, REFRESH_FAILED, PASSWORD_TOO_WEAK, PASSWORD_PWNED, PASSWORD_RESET_FAILED, CHANGE_PASSWORD_FAILED, PASSKEY_REGISTER_FAILED, PASSKEY_AUTHENTICATE_FAILED, PASSKEY_AUTOFILL_UNAVAILABLE, PASSKEY_AUTOFILL_FAILED, PASSKEY_AUTOFILL_ABORTED, OAUTH_POPUP_BLOCKED, OAUTH_STATE_MISMATCH, OAUTH_CALLBACK_FAILED, MFA_REQUIRED, MFA_VERIFY_FAILED, TOTP_ENROLL_FAILED, RECOVERY_CODE_INVALID, EMAIL_VERIFY_FAILED, PHONE_VERIFY_FAILED, INVALID_OPTIONS, SSR_NO_OP, IDLE_TIMEOUT, FORCED_LOGOUT. Every code SHALL be raised by at least one production path; every translation SHALL preserve the underlying cause.

Scenario: every declared code is reachable

  • WHEN the test suite enumerates the registry
  • THEN for each code there exists a production code path that raises it (verified by call-graph or fixture)

Verified by: src/errors/errors.test.ts::"every REACT_AUTH code is reachable from a production path" AND src/errors/errors.test.ts::"every translation preserves cause"


Requirement: Zod schemas validate every hook option bag at boundary

The package SHALL validate every public hook's option bag through a Zod schema exported from src/schemas/schemas.ts. Boundary mismatches SHALL throw REACT_AUTH.INVALID_OPTIONS synchronously so React's nearest error boundary catches the failure at mount time.

Scenario: invalid sign-in option throws INVALID_OPTIONS

  • WHEN useSignIn().mutate({ email: "", password: "" }) is called
  • THEN the mutation rejects with REACT_AUTH.INVALID_OPTIONS

Scenario: invalid useOAuthSignIn provider throws INVALID_OPTIONS

  • WHEN useOAuthSignIn().mutate({ provider: "", mode: "popup" }) is called
  • THEN the mutation rejects with REACT_AUTH.INVALID_OPTIONS

Verified by: src/schemas/schemas.test.ts::"rejects empty sign-in fields" AND src/schemas/schemas.test.ts::"rejects empty oauth provider"


Requirement: i18n catalogues for en-GB, cy-GB, zh-CN are parity-checked

The package SHALL ship react-auth.* message catalogues at src/i18n/{en-gb,cy-gb,zh-cn}/index.ts. The catalogue key set SHALL be identical across the three locales. A parity test SHALL fail CI when any locale's key set differs from en-GB.

Scenario: parity test fails on missing key

  • WHEN cy-GB omits a key that exists in en-GB
  • THEN the parity test fails with the missing key listed

Scenario: parity test fails on extra key

  • WHEN zh-CN declares a key not present in en-GB
  • THEN the parity test fails with the orphan key listed

Verified by: src/i18n/parity.test.ts::"every locale has the same keys as en-GB" AND src/i18n/parity.test.ts::"no locale declares keys absent from en-GB"


Requirement: No hardcoded user-visible strings in components

Every user-visible string in any src/components/**/*.tsx SHALL resolve via the react-auth.* i18n catalogue. A guard test SHALL scan component source for hardcoded English strings and fail on any match outside an allowlist of fixture/identifier strings.

Scenario: a hardcoded button label is rejected

  • WHEN a component literal contains > Sign in < (or equivalent visible English text) in JSX
  • THEN the guard test fails citing the file and line

Verified by: src/i18n/no-hardcoded-strings.test.ts::"every component string resolves via i18n"


Requirement: Subpath exports cover every hook, component, oauth helper, session module, errors, and i18n locale

The package.json exports map SHALL declare one tree-shakeable entry per feature module. At minimum the map SHALL include: ., ./auth-provider, ./hooks/use-auth, ./hooks/use-user, ./hooks/use-is-authenticated, ./hooks/use-session, ./hooks/use-sign-in, ./hooks/use-sign-up, ./hooks/use-sign-out, ./hooks/use-refresh-token, ./hooks/use-change-password, ./hooks/use-password-reset, ./hooks/use-register-passkey, ./hooks/use-authenticate-with-passkey, ./hooks/use-passkey-autofill, ./hooks/use-oauth-sign-in, ./hooks/use-enroll-totp, ./hooks/use-verify-totp, ./hooks/use-enroll-webauthn-second-factor, ./hooks/use-recovery-codes, ./hooks/use-email-verification, ./hooks/use-phone-verification, ./hooks/use-permission, ./hooks/use-auth-guard, ./components/auth-guard, ./components/permission-gate, ./components/password-input, ./components/password-strength-meter, ./components/hibp-pwned-indicator, ./components/password-generator-button, ./components/passkey-autofill-field, ./components/passkey-list, ./components/oauth-provider-button, ./components/totp-enrollment-panel, ./components/recovery-codes-panel, ./components/email-verification-view, ./components/phone-verification-view, ./components/auth-error-boundary, ./oauth/handle-callback, ./oauth/provider-registry, ./session/storage, ./session/broadcast-channel, ./session/idle-timer, ./errors, ./i18n/en-gb, ./i18n/cy-gb, ./i18n/zh-cn.

Scenario: every documented subpath resolves to declared symbols

  • WHEN a consumer imports each documented subpath
  • THEN every import resolves to the symbols listed in proposal.md's public API

Verified by: src/index.test.ts::"every documented subpath resolves"


Requirement: 100% Istanbul coverage with no carve-outs

The package SHALL meet 100% statement, 100% branch, 100% function, and 100% line coverage under @vitest/coverage-istanbul for src/. The pre-publish gate (bunx turbo test:coverage --filter=@suaveplan/react-auth) SHALL hard-fail below 100%.

Scenario: coverage gate hard-fails below 100%

  • WHEN bunx turbo test:coverage --filter=@suaveplan/react-auth reports any metric < 100%
  • THEN the command exits non-zero

Verified by: vitest.config.ts::"thresholds set to 100/100/100/100" AND package.json::"test:coverage script wired"


Requirement: Playwright E2E covers password, passkey, OAuth, MFA, verification, multi-tab, idle, error-boundary, RTL, and accessibility

The package SHALL ship a Playwright E2E suite under e2e/ containing at minimum: 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. The accessibility spec SHALL fail on any serious or critical axe violation.

Scenario: accessibility sweep finds zero serious or critical violations

  • WHEN e2e/accessibility.spec.ts runs against the canonical app
  • THEN @axe-core/playwright reports zero serious and zero critical violations

Scenario: multi-tab sync propagates sign-out

  • WHEN tab A signs out
  • THEN within 2 s tab B's UI reflects the unauthenticated state

Verified by: e2e/accessibility.spec.ts::"every canonical page passes axe with zero serious or critical violations" AND e2e/multi-tab-sync.spec.ts::"sign-out in one tab propagates within 2 seconds" AND e2e/passkey-autofill.spec.ts::"conditional UI delivers credential through browser autofill"