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. (Seeopenspec/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/BroadcastChannelaccess 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
StorageLikecontains a valid persisted session - THEN the provider hydrates
stateto{ status: "authenticated", user, accessToken, expiresAt, refreshAt }insideuseEffect - 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
SuaveplanErrorwithcode === "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()returnsnull - AND
useIsAuthenticated()returnsfalse
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-inenvelope is posted on the broadcast channel
Scenario: backend failure surfaces SIGN_IN_FAILED with cause preserved
- WHEN
AuthBackend.passwordSignInrejects with an error - THEN
useSignIn().erroris aSuaveplanErrorwithcode === "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
StorageLikeno longer contains the persisted session - AND a
signed-outenvelope 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.changePasswordis 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 callsconfirm.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>carriesfor="pwd" - AND the rendered
<input>carriesaria-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-pressedstate reflects the visibility
Scenario: axe-core finds zero serious violations
- WHEN
<PasswordInput>is rendered in the canonical story - THEN
axe.runreports zeroseriousorcriticalviolations
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
passwordprop changes 100 times within a single tick - THEN at most one
assessReactivePromise is still pending after settle - AND no
unhandled rejectionis logged
Scenario: unmount cancels the in-flight assessment
- WHEN the component unmounts while an
assessReactivePromise is pending - THEN the underlying
AbortControlleraborts - 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
HibpClientis called
Scenario: rapid keystrokes coalesce to a single request
- WHEN the
passwordprop changes 10 times in under 500 ms - THEN the
HibpClient.checkPasswordmethod 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
onPickis 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, andpasskeyRegisterFinishare called in order - AND the resolved value is the
RegisteredPasskeyfrom 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
passkeyAuthenticateFinishrejects - THEN the mutation rejects with
REACT_AUTH.PASSKEY_AUTHENTICATE_FAILEDwith 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()resolvestrue - THEN
webauthn.authenticateis called withmediation: "conditional"and the forwarded option fields - AND
readyflips totrueonce 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/PublicKeyCredentialaccess occurs - AND the return is
{ ready: false, error: null, abort }
Scenario: missing Conditional UI surfaces PASSKEY_AUTOFILL_UNAVAILABLE
- WHEN
isConditionalMediationAvailableis missing or resolvesfalse - THEN
authenticateis never called andonError(if supplied) receivesREACT_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
AbortErrortranslated toREACT_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-Uint8Arraychallenge - THEN the hook throws
REACT_AUTH.INVALID_OPTIONSduring render - AND no
authenticatecall 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>hasautocomplete="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 withREACT_AUTH.PASSKEY_AUTOFILL_ABORTED
Scenario: blur after credential delivery is a no-op
- WHEN
onCredentialhas 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.revokePasskeyis called with the row'scredentialId - 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.openreturnsnull - THEN the mutation rejects with
REACT_AUTH.OAUTH_POPUP_BLOCKED
Scenario: state mismatch surfaces OAUTH_STATE_MISMATCH
- WHEN the callback
postMessagecarries astatethat does not match the persisted PKCE state - THEN the mutation rejects with
REACT_AUTH.OAUTH_STATE_MISMATCH - AND no
oauthExchangecall is made
Scenario: cross-origin postMessage is ignored
- WHEN the popup receives a
postMessagefrom 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.assignis 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
stateparameter - THEN the function rejects with
REACT_AUTH.OAUTH_STATE_MISMATCH
Scenario: idempotent across remounts
- WHEN
handleOAuthCallback()is called twice with the samestate - THEN
AuthBackend.oauthExchangeis 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().mutateis invoked
Scenario: registry rejects duplicate registrations
- WHEN
registeris 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 thenfinish.mutate({ code })resolves - THEN the consumer receives
{ secret, provisioningUri }frombeginand{ recoveryCodes }fromfinish
Scenario: TOTP verification transitions out of mfa-required
- WHEN provider state is
mfa-requiredanduseVerifyTotp().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 thebeginstate - 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-acknowledgewarning is announced viaaria-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-liveregion announces the remaining time
Scenario: phone code input is optimised for mobile autofill
- WHEN
<PhoneVerificationView>is rendered - THEN the code
<input>carriesinputMode="numeric"andautoComplete="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 stateunauthenticated - THEN the
data-testid="x"element renders and children do not
Scenario: AuthGuard throws UNAUTHENTICATED when no fallback
- WHEN
<AuthGuard>is mounted with nofallbackand provider stateunauthenticated - THEN a
SuaveplanErrorwithcode === "REACT_AUTH.UNAUTHENTICATED"is thrown during render
Scenario: PermissionGate consults injected resolver
- WHEN
<PermissionGate permission="admin">is mounted andpermissions.has("admin")returnsfalse - 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-outenvelope - THEN tab B's provider state transitions to
unauthenticated - AND no
AuthBackend.sessionRevokecall 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-logoutenvelope 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.sessionRefreshis called exactly once
Scenario: refresh failure clears state
- WHEN the scheduled
sessionRefreshrejects - THEN provider state transitions to
unauthenticatedwithREACT_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
pointerdownfires before the warning threshold - THEN the warning + timeout timers are reset
Scenario: warning then timeout fire in order
- WHEN the clock advances past
ttlMs - warnBeforeMsthen pastttlMs - THEN
onWarnfires first, thenonTimeout - AND provider state transitions to
unauthenticatedwithREACT_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-GBomits a key that exists inen-GB - THEN the parity test fails with the missing key listed
Scenario: parity test fails on extra key
- WHEN
zh-CNdeclares a key not present inen-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-authreports 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.tsruns against the canonical app - THEN
@axe-core/playwrightreports zeroseriousand zerocriticalviolations
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"