130 lines
7.6 KiB
Markdown
130 lines
7.6 KiB
Markdown
# Design — `usePasskeyAutofill` (`@suaveplan/react-auth`)
|
|
|
|
Non-normative. Architecture, lifecycle, and rationale. The normative contract lives in `specs/react-auth/spec.md`.
|
|
|
|
## Package shape
|
|
|
|
`@suaveplan/react-auth` is created by this change. It is a lightweight browser-tier package at `packages/auth/react-auth/`. Tier is `browser` (React 19 peer dep, DOM types fine). It holds exactly this hook and the optional `<PasskeyAutofillField>` wrapper today; future auth-related React hooks land in follow-up changes under the same package.
|
|
|
|
The hook lives at `src/use-passkey-autofill/use-passkey-autofill.ts`; the component at `src/passkey-autofill-field/passkey-autofill-field.tsx`; errors at `src/errors/errors.ts`. Each has a sibling `.md`, a `.test.ts` (or `.test.tsx`), and stories per CLAUDE.md.
|
|
|
|
## Conditional UI background
|
|
|
|
WebAuthn Level 3 §5.1.4 defines `mediation: "conditional"`. Browsers that support it surface available platform credentials in the autocomplete sheet of any `<input>` with `autocomplete="username webauthn"`. The feature is gated on `PublicKeyCredential.isConditionalMediationAvailable()` — a static method that does not exist on Safari < 17, Firefox < 119, or any SSR environment. Calling `navigator.credentials.get({ publicKey, mediation: "conditional" })` without that check throws `NotSupportedError` on older browsers, which `@suaveplan/webauthn` maps to `WEBAUTHN.NOT_SUPPORTED`.
|
|
|
|
The hook MUST therefore feature-detect before dispatching. Because feature detection is async and DOM-only, it can only run inside a `useEffect`.
|
|
|
|
## Hook signature
|
|
|
|
```ts
|
|
interface UsePasskeyAutofillOptions {
|
|
rpId: string;
|
|
challenge: Uint8Array;
|
|
onCredential: (result: AuthenticateResult) => void;
|
|
onError?: (error: SuaveplanError) => void;
|
|
signal?: AbortSignal;
|
|
allowCredentials?: PublicKeyCredentialDescriptor[];
|
|
userVerification?: UserVerificationRequirement;
|
|
timeout?: number;
|
|
expectedOrigin?: string;
|
|
expectedRpIdHash?: Uint8Array;
|
|
}
|
|
|
|
interface UsePasskeyAutofillReturn {
|
|
ready: boolean;
|
|
error: SuaveplanError | null;
|
|
abort: () => void;
|
|
}
|
|
|
|
function usePasskeyAutofill(opts: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn;
|
|
```
|
|
|
|
`ready` flips to `true` after `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` Promise is in-flight. `error` carries the most recent translated error (cleared when a new attempt starts). `abort` is a stable `useCallback` that fires the internal `AbortController`.
|
|
|
|
## Lifecycle — AbortController ownership
|
|
|
|
The hook owns exactly one `AbortController` per "session" (one mount, one option-identity). The controller is composed with the caller's `options.signal` via a small `composeSignals(...signals)` helper (any aborts → composed aborts).
|
|
|
|
| Event | Behavior |
|
|
|---|---|
|
|
| Mount (client) | Run feature detect → dispatch `authenticate({ mediation: "conditional", signal: composed })`. |
|
|
| Mount (server) | No-op. The `useEffect` never runs. |
|
|
| Unmount | Call `controller.abort()`. The underlying `navigator.credentials.get` rejects with `AbortError`, mapped to `WEBAUTHN.ABORTED` → `REACT_AUTH.ABORTED`. |
|
|
| Caller `signal` aborts | Composed controller aborts → same as unmount path. |
|
|
| Caller calls returned `abort()` | Same as above. State remains mounted; consumer may re-trigger by changing option identity. |
|
|
| `<input>` blur (via `<PasskeyAutofillField>`) | Consumer-controlled. The component calls `abort()` on `onBlur` IF no credential has been delivered yet. The hook alone does not subscribe to blur (it does not touch the DOM). |
|
|
| Option identity changes (e.g. fresh `challenge`) | Effect re-runs: old controller aborts, new controller created, new request dispatched. |
|
|
| `onCredential` fires | Controller is left intact (the request is already settled); `error` cleared. |
|
|
| `onError` fires | Controller aborted; `error` stored; consumer may remount or change challenge to retry. |
|
|
|
|
The hook MUST NOT touch the DOM directly — it works alongside an `<input autocomplete="username webauthn">` rendered by the consumer. The browser surfaces the autofill UI based on the autocomplete token; the hook just keeps the conditional `authenticate` request pending so the browser has something to deliver into.
|
|
|
|
## SSR safety
|
|
|
|
- `typeof window === "undefined"` short-circuits before any `navigator` access.
|
|
- `isConditionalMediationAvailable()` is called inside `useEffect` (which never runs server-side under React 19 SSR).
|
|
- The hook returns `{ ready: false, error: null, abort: noop }` on the server render, then re-renders on hydration.
|
|
|
|
## Error translation
|
|
|
|
```text
|
|
WEBAUTHN.NOT_SUPPORTED → REACT_AUTH.AUTOFILL_UNAVAILABLE
|
|
WEBAUTHN.ABORTED → REACT_AUTH.ABORTED
|
|
all other WEBAUTHN.* → REACT_AUTH.AUTOFILL_FAILED (cause-chained)
|
|
Zod schema rejection at entry → REACT_AUTH.INVALID_OPTIONS (thrown synchronously)
|
|
SSR / non-DOM environment → REACT_AUTH.SSR_NO_OP (surfaced via onError only)
|
|
```
|
|
|
|
Every translation preserves the original `SuaveplanError` via `cause`, so consumers can introspect `error.cause?.code` for the underlying `WEBAUTHN.*` code.
|
|
|
|
## Testing approach
|
|
|
|
- Real `navigator.credentials.get` stub at the jsdom layer — install a fake `PublicKeyCredential` with `isConditionalMediationAvailable` and a `navigator.credentials.get` that resolves a fixture `AuthenticateResult` or rejects with a chosen `DOMException`. Tests live in `src/use-passkey-autofill/use-passkey-autofill.test.tsx` using `@suaveplan/testing-dom` render utilities and `@suaveplan/testing/runner` for the runner-neutral imports (CLAUDE.md rule 11).
|
|
- For environments lacking `PublicKeyCredential` entirely (deliberately stripped), assert `REACT_AUTH.AUTOFILL_UNAVAILABLE` is reported via `onError` and `ready` stays `false`.
|
|
- Lifecycle is verified by mounting, asserting an in-flight request, unmounting, and asserting the request rejected with `REACT_AUTH.ABORTED`.
|
|
- 100% coverage per CLAUDE.md / openspec rule 7 — no carve-outs.
|
|
|
|
## File layout
|
|
|
|
```text
|
|
packages/auth/react-auth/
|
|
README.md
|
|
package.json
|
|
tsconfig.json
|
|
vitest.config.ts
|
|
src/
|
|
index.ts # explicit named re-exports (rule 6a)
|
|
use-passkey-autofill/
|
|
index.ts
|
|
use-passkey-autofill.ts
|
|
use-passkey-autofill.md
|
|
use-passkey-autofill.test.tsx
|
|
use-passkey-autofill.stories.tsx
|
|
use-passkey-autofill.types.ts
|
|
use-passkey-autofill.schemas.ts
|
|
passkey-autofill-field/
|
|
index.ts
|
|
passkey-autofill-field.tsx
|
|
passkey-autofill-field.md
|
|
passkey-autofill-field.test.tsx
|
|
passkey-autofill-field.stories.tsx
|
|
passkey-autofill-field.types.ts
|
|
errors/
|
|
index.ts
|
|
errors.ts
|
|
errors.md
|
|
errors.test.ts
|
|
utils/
|
|
compose-signals.ts
|
|
compose-signals.md
|
|
compose-signals.test.ts
|
|
```
|
|
|
|
## Decisions
|
|
|
|
- **Hook-first, component-optional.** The `<PasskeyAutofillField>` wrapper exists for the common case but is never a requirement; advanced consumers wire the hook to their own input.
|
|
- **No global state, no context provider.** Multiple `usePasskeyAutofill` instances may coexist (e.g. a multi-tenant login page); each owns its own controller.
|
|
- **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes. The underlying `WEBAUTHN.*` cause is preserved on `error.cause` for introspection.
|
|
- **`onCredential` is required, `onError` is optional.** A consumer that does not pass `onError` accepts that errors silently no-op (with `error` still populated on the return value).
|
|
- **Stable `abort` reference.** Returned via `useCallback` so consumers can put it in dependency arrays without thrashing.
|