Skip to content

2.3.0: extract the passkey assertion and known-device abstracts - #13

Merged
ondrej-kuhnel merged 2 commits into
mainfrom
release/2.3.0
Aug 24, 2026
Merged

ondrej-kuhnel merged 2 commits into
mainfrom
release/2.3.0

Conversation

@ondrej-kuhnel

Copy link
Copy Markdown
Member

Item 4 of the review notes from 3brs/sylius-enterprise-security-plugin — the two abstracts every consumer currently writes for itself. Additive: no existing interface changes, nothing a consumer has today stops working.

Why these two, and not "less duplication"

The plugin has both pairs twice (customer + admin), 45 and 77 lines each, differing in 8 and 20 lines. What the copies share is not boilerplate — each one carries an invariant that has to survive being edited:

  • AbstractPasskeyAssertionVerifier — the sign-count write has to be atomic with the WebAuthn check, or two concurrent assertions replaying the same authenticator response both pass before either one's counter lands. That is why commit() is a hook inside the verifier rather than a flush the caller does afterwards. The integration guide previously shipped the whole ~40-line ceremony as a skeleton to copy, so the invariant was restated in every consumer.
  • AbstractNewDeviceDetector (+ KnownDeviceRecordInterface) — "is this device new?" and "remember it" must not be separable, or two concurrent sign-ins from the same device both read unknown and the user gets two "new device" mails for one login. The insert race is settled by the unique key on the consumer's (user, fingerprint) columns; the subclass names the exception that means conflict, so no Doctrine dependency enters the bundle — the same split AbstractSessionTracker already uses.

Shape

Both follow the existing AbstractSessionTracker pattern: orchestration in the bundle, framework-coupled primitives (repository lookup, record factory, persist / flush / detach, conflict detection) as abstract hooks. docs/interface-implementations.md now shows the subclass form for both instead of a skeleton to copy.

Verified against a real consumer

The plugin's four classes were rewritten onto these abstracts locally (−135/+117 lines, both duplicated bodies gone): its ECS, PHPStan, lint:container and 586 PHPUnit tests pass, and the 12 pre-existing tests covering those four classes pass unchanged — the strongest evidence available that the extraction preserves behaviour. That plugin PR follows once this is tagged; it needs ~2.3.0 to resolve.

Checks

phpunit 399 tests / 1484 assertions (9 new), phpstan analyse clean, ecs check clean, composer validate --strict OK.

Not in scope

Webauthn\PublicKeyCredentialSource is deprecated upstream since webauthn-lib 5.3 in favour of CredentialRecord. The abstract keeps the plugin's current usage verbatim — migrating touches the shape of the persisted credentialSource, so it belongs in its own change.

🤖 Generated with Claude Code

ondrej-kuhnel and others added 2 commits August 24, 2026 09:57
…erifier

Every consumer wrote the verify half of a WebAuthn assertion out by hand — the
integration guide even shipped a ~40-line skeleton to copy, and the plugin has two
near-identical copies of it (77 lines each, 20 of them actually different). What the
copies share is not boilerplate: it is the ordering the ceremony depends on.

The abstract runs it — consume the pending options, refuse a response that is not an
assertion, look the credential up by raw id, run the library check, write the updated
source and lastUsedAt back — and leaves the subclass the four firewall-specific things:
the options session key, the credential's owner, the result DTO, and the flush.

`commit()` is a hook rather than something the caller does afterwards on purpose. The
sign-count write has to be atomic with the check, or two concurrent assertions replaying
the same authenticator response both pass before either one's counter lands. That
invariant now lives in one place instead of being restated in each copy.

Additive: `PasskeyAssertionVerifierInterface` is untouched, so an existing hand-written
implementation keeps working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "have I seen this device for this user?" step behind login notifications was written
per consumer, twice in the plugin alone (45 lines each, 8 of them different). Answering
and remembering must stay one step: split them and two concurrent sign-ins from the same
device both read "unknown", so the user gets two "new device" mails for one login.

The abstract keeps that shape, including the recovery it depends on — the unique key on
the consumer's (user, fingerprint) columns is what makes the losing insert fail, and the
subclass says which exception means conflict (`isConcurrentInsertConflict`) so the bundle
stays free of a Doctrine dependency. Same split `AbstractSessionTracker` already uses for
the same reason.

`KnownDeviceRecordInterface` types the row, matching how every other persisted record the
bundle touches is contracted: record data here, the user association on the consumer's
entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ondrej-kuhnel
ondrej-kuhnel merged commit 0026c3c into main Aug 24, 2026
2 checks passed
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