Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
- `verifyAuthenticationResponse` already loads the credential row to
derive UV requirements; pass it through to `verifyAssertion` instead
of repeating the lookup.
- `getAvailableMfaMethods` ran two independent reads sequentially —
fan them out with `Promise.all`. Hit on every MFA-required login.
- `deleteWebAuthnCredential` only needed to know whether TOTP is set,
but called `getSecretAndRecoveryCodes` which AES-decrypts the secret
plus every recovery code. Add a lightweight `hasTotpSecret(userId)`
and use that instead.
- Replace bare `throw new Error` with `OperationalError`, drop the
WebAuthn section banner and a couple of comments that just
paraphrased the surrounding code.
The same predicate lived in `webauthn.service.ts` and was re-declared
inline in `SettingsPersonalView.vue`. Move the single source of truth
into `@n8n/api-types` so FE and BE classify a credential identically.
Each WebAuthn credential pushed its kind onto the returned array, so a
user with two passkeys saw `['totp', 'passkey', 'passkey', ...]`. The
change-password switcher walks the list with `indexOf`+1 and ended up
landing on the duplicate, making the next method unreachable.
The route declared an inline `{ token: string }` body type, which the
controller registry cannot resolve, so the payload arrived `undefined`
and the controller crashed on destructuring. Introduce a DTO class and
add coverage for the bad-body and unauthenticated paths.
Recovery codes belong to the TOTP setup flow only. WebAuthn's natural
fallback is registering multiple credentials (passkey + security key +
backup passkey), so generating + presenting recovery codes alongside
the very first webauthn credential mixed two flows and made the setup
wizard inconsistent.
- `verifyWebAuthnRegistration` no longer calls `generateRecoveryCodes`
or includes them in the response.
- `WebAuthnSetupWizardModal.vue` drops the recovery-codes step (and the
associated download button, CSS, and unused imports).
- `webauthn.ts` rest-api-client typing drops the optional
`recoveryCodes` from the verify-registration response.
- The TOTP setup flow (`activateMFA` / `getQRCode` / TotpSetupWizard) is
unchanged — TOTP still issues recovery codes as before.
Also extends `ApiHelpers.resetDatabaseWith()` so a Playwright spec can
re-seed the owner without a TOTP `mfaSecret` (the webauthn spec uses
this so its MFA login screen only offers webauthn methods).
The cross-platform registration options omitted `authenticatorAttachment`,
so Chrome offered the user's existing platform passkey alongside the
security-key prompt. Picking the already-registered passkey triggered an
`InvalidStateError` from the browser via `excludeCredentials`, surfacing
as a generic "Could not register" in the UI.
Set `authenticatorAttachment: 'cross-platform'` so the browser only
offers roaming authenticators during security-key registration, and
surface the underlying error message in the modal so future failures are
debuggable from devtools.
Master used to reject stale password-reset tokens via `createJWTHash`,
which embedded the user's password hash in the JWT. This branch replaced
that mechanism with `tokensValidAfter` — a per-user timestamp the server
bumps on any sensitive account change. The corresponding test now mocks
the new mechanism (bumping `tokensValidAfter` past the token's `iat`)
rather than the old one (signing a token against a fake user with a
different password).
Closes the remaining gaps in the WebAuthn test surface.
Integration (`webauthn.api.test.ts`) — 5 new cases for
`POST /mfa/webauthn/authentication-options`:
- no kind → all credentials in `allowCredentials`
- `kind: 'passkey'` → only platform credentials
- `kind: 'security_key'` → only roaming credentials
- missing email → 400
- user has no MFA enabled or doesn't exist → MFA 998 error
Controller (`mfa.controller.test.ts`) — 14 new mock-only branches across
`getWebAuthnRegistrationOptions`, `verifyWebAuthnRegistration`,
`getWebAuthnAuthenticationOptions`, and `updateWebAuthnCredential`. Covers
input validation, the defensive `verified: false` / `registrationInfo: null`
paths, the 998 no-enumeration response, and label trimming.
Extends the helper with `buildAuthenticationResponse` — signs
`sha256(authData || sha256(clientDataJSON))` with the credential's ECDSA
P-256 private key — and adds two test suites driving the real
`@simplewebauthn/server` verifier:
- Per-credential authentication via `/mfa/webauthn/credentials/:id/remove`:
valid passkey (UV required) and security key (UV optional), missing-UV
rejection on passkeys, wrong-key signature, tampered-signature byte flip,
rpId mismatch.
- Passwordless login via `/login/webauthn/options` + `/verify`: success
with cookie + counter/lastUsedAt update, wrong-key, disabled user,
unknown credentialId, unknown challengeId.
Together with the earlier registration tests, this exercises both
`verifyAuthenticationResponse` and `verifyPasswordlessAuthentication` —
including the per-credential `requireUserVerification` branch.
Drives `POST /mfa/webauthn/registration-verify` end-to-end through the real
`@simplewebauthn/server` verifier with a small Node-side helper that builds
valid `RegistrationResponseJSON` objects from a freshly generated ECDSA P-256
keypair. Hand-rolled CBOR for the COSE_Key + `fmt:'none'` attestation; no
new npm deps.
Six cases: platform→passkey, cross-platform→security_key, idempotent recovery
codes on a second registration, rpIdHash mismatch (canary that proves the
crypto path runs), missing UP flag, and stale-challenge replay.
First of several chunks — authentication assertion, passwordless login, and
kind-filtered options land in follow-up commits.
Backend unit tests:
- `isPlatformCredential` (transports/deviceType matrix)
- `countMfaProofs` (proof counting incl. empty/null edge cases)
- `MfaService.validateProof` (webauthn vs TOTP routing)
- `AuthService.rotateSession` (ordering + JWT payload)
- `MFAController.deleteWebAuthnCredential` (proof gate, last-credential
guard, MFA state cleanup, session rotation)
E2E:
- Update `SettingsPersonalPage` to the new `mfa-method-totp-*` test IDs
- Update `MfaSetupModal` to the new `totpSetupWizard-modal` container
and add the `mfa-continue-button` step in the two-step TOTP wizard
Unify the four divergent `isPlatform` heuristics behind a single
`isPlatformCredential` helper, extract a shared `MfaProof` DTO with
`validateProof`/`countMfaProofs`, deduplicate the webauthn assertion
verification between per-user and passwordless flows, and add
`rotateSession`/`completeLogin` helpers to remove repeated
session-rotation and login-finalization boilerplate. Also drops dead
SCSS, the unused `_intent` arg, the duplicate `TwoFactorMethod` type
(reuse `MfaMethod`), and the unsafe `as never` i18n key casts. Trims
redundant DB reads in `updateCurrentUser`, `enableMfa`, and the recovery
code rotation path.
- Track lastUsedAt per webauthn credential (new column + migration)
and surface it as "Last used X ago" in the credentials list.
- Personal settings restructured to one card per method with a nested
credentials list, device-aware label pre-fill on the setup wizard,
and a dashed "Add another …" button.
- Login MFA prompt picks the method the user signed in with last on
this device (localStorage) and shows a switcher link when both
TOTP and a webauthn credential are registered.
- Credential removal re-verifies with the same kind of authenticator
being removed (passkey → passkey, security key → security key) via
a narrowed allowCredentials, falling back to the TOTP / recovery
code prompt when the device is unavailable.
- Refuse to delete the last MFA credential while 2FA is enforced.