Skip to content

✨ FEATURE: Add passkey support#52

Open
tobiasgruber wants to merge 14 commits into
sandstorm:mainfrom
tobiasgruber:add-passkey-support
Open

✨ FEATURE: Add passkey support#52
tobiasgruber wants to merge 14 commits into
sandstorm:mainfrom
tobiasgruber:add-passkey-support

Conversation

@tobiasgruber

@tobiasgruber tobiasgruber commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Add config-opt-in option for passkey login support for Neos (username-less and password-less).

  • multiple passkeys allowed per user
  • can exist in parallel to username/password and second factors - also with webAuthn

Needs a database migration: ./flow doctrine:migrate

Needs to be tested in production with different passkey providers:

  • Browsers (Chrome, Firefox, Safari...)
  • Password Managers

tobiasgruber and others added 14 commits June 27, 2026 22:36
Add usernameless, passwordless passkey login to the Neos backend login screen, alongside the existing WebAuthn 2nd factor. A user-verified discoverable passkey authenticates the Neos.Neos:Backend account directly (no password) and satisfies the 2FA gate in one tap.

Off by default: gated behind webAuthn.passwordlessLoginEnabled, enforced server-side in the controller (not just by hiding the button).

Architecture: a parallel Flow auth provider + token (WebAuthnPasswordlessProvider / WebAuthnPasswordlessToken) authenticate the same backend account in-request; with Neos' oneToken strategy and party-based user resolution this grants full backend access. The token is persisted via refreshTokens() and survives the redirect to /neos.

- WebAuthnService: createPasswordlessAuthenticationOptions (empty allowCredentials, UV required), verifyPasswordlessAssertion, and the unit-tested resolveBackendAccountByUserHandle guard (only Neos.Neos:Backend accounts may use this path). Registration requests discoverable (resident)    credentials only when passwordless is enabled, so U2F-only 2nd-factor behaviour is unchanged when off.
 - PasswordlessLoginController (options/verify XHR, SkipCsrfProtection), Routes.yaml, Settings.yaml (provider), Policy.yaml (public grant for the endpoints), Settings.2FA.yaml (passwordlessLoginEnabled: false), new session key, and session start for the logged-out flow.
- Login screen: Views.yaml extends the core Neos login FusionView to load a server-gated PasswordlessLoginButton that also loads webauthn.js; JS reuses the assertion ceremony (click-only, no auto-trigger).
- Tests: unit test for the account-resolution guard; E2E "passwordless" variant (happy path) and "disabled by default" scenarios (no button + 403).
- E2E infra: reuseExistingServer + KEEP_SUT for a fast local loop, mount Tests/ into the SUT so PHPUnit runs there, and make removeAllUsers tolerant of zero users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `discoverable` flag to SecondFactor so a passwordless-capable credential ("Passkey") is distinguished from a non-discoverable one    ("Passkey as 2nd factor"). Credentials registered while passwordless login is enabled use residentKey:required and are persisted as discoverable; legacy rows default to non-discoverable (migration).

Surface the distinction as a per-credential badge in the management module, and nudge users without a Passkey to register one via a CTA banner (gated by the passwordless setting). Rename all user-facing "security key" wording to "passkey" across labels and translations.

Add E2E coverage for the Passkey badge, the registration banner, the post-login redirect to the originally requested page, and cancelling the passkey sign-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… top) and style the twofactor backend module more consistently
…ogin

An abandoned WebAuthn passwordless login left the shared session container (SESSION_OBJECT_ID) populated with only `webAuthnPasswordlessOptions` and no authentication status. A later regular login then crashed the SecondFactorMiddleware:
SecondFactorSessionStorageService::getAuthenticationStatus(): Return value must be of type string, null returned

Root cause: initializeTwoFactorSessionObject() keyed its "already initialised?" check on the presence of the whole container, but the passwordless flow (PasswordlessLoginController::optionsAction) writes its options into that container before any status exists — so the status was never set, and the string-typed getter returned null.

Fixes:
- initializeTwoFactorSessionObject() now gates on the auth status key, not the container's presence (root cause).
- getAuthenticationStatus() defaults a missing status to AUTHENTICATION_NEEDED (defence in depth against the TypeError).

Regression tests:
- Tests/Unit/Service/SecondFactorSessionStorageServiceTest.php (new)
- Tests/E2E/features/passwordless/login.feature: "Abandoning a passwordless ceremony does not break a later password login"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant