From 3c09c6bc6e4edbcf656739760fa380f928306bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Fri, 11 Sep 2026 15:14:42 +0100 Subject: [PATCH] feat: Fire an event when a passkey is revoked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Events::USER_DID_REMOVE_PASSKEY, dispatched from Service\Passkey::revoke() after a successful delete, regardless of what triggered it (self-service or admin). This is a live event alongside the existing did_remove_passkey audit log entry, not a replacement for it. Without this there was no way for anything outside this module to notice a passkey had gone and react — needed by the MFA module's passkey driver, which must un-enroll the "Passkey" MFA method once a user's last passkey is revoked, otherwise they are left enrolled in a method they can never again satisfy. Co-Authored-By: Claude Sonnet 5 --- src/Events.php | 12 ++++++++++++ src/Service/Passkey.php | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Events.php b/src/Events.php index 8d4d5559..45e0e6a5 100644 --- a/src/Events.php +++ b/src/Events.php @@ -85,4 +85,16 @@ class Events extends Base * @param int[] $aMergeIds The IDs of the users who were merged into $iKeepId */ const USER_MERGE_COMPLETE = 'AUTH:USER:MERGE:COMPLETE'; + + /** + * Fired after a passkey is revoked + * + * Fires regardless of what deleted it (self-service, admin, or otherwise), so + * anything that treats "has a passkey" as meaningful — an MFA driver built on + * top of passkeys, for instance — can react without this module knowing it + * exists. + * + * @param int $iUserId The ID of the user the passkey belonged to + */ + const USER_DID_REMOVE_PASSKEY = 'AUTH:USER:PASSKEY:REMOVED'; } diff --git a/src/Service/Passkey.php b/src/Service/Passkey.php index 884598e8..b51c4b0e 100644 --- a/src/Service/Passkey.php +++ b/src/Service/Passkey.php @@ -15,6 +15,7 @@ use lbuchs\WebAuthn\WebAuthn; use lbuchs\WebAuthn\WebAuthnException; use Nails\Auth\Constants; +use Nails\Auth\Events; use Nails\Auth\Exception\Passkey\ChallengeException; use Nails\Auth\Exception\Passkey\CredentialExistsException; use Nails\Auth\Exception\Passkey\InvalidResponseException; @@ -28,6 +29,7 @@ use Nails\Common\Exception\FactoryException; use Nails\Common\Exception\ModelException; use Nails\Common\Service\Cookie; +use Nails\Common\Service\Event; use Nails\Common\Service\Input; use Nails\Common\Service\Session; use Nails\Config; @@ -809,6 +811,7 @@ public function revoke(Resource\User\Passkey $oPasskey, array $aEventData = []): $bResult = $this->getModel()->delete((int) $oPasskey->id); if ($bResult) { + createUserEvent( 'did_remove_passkey', array_merge( @@ -818,6 +821,19 @@ public function revoke(Resource\User\Passkey $oPasskey, array $aEventData = []): null, (int) $oPasskey->user_id ); + + /** + * A live event, not the audit log above: anything that treats "has a + * passkey" as meaningful (an MFA driver built on top of passkeys, for + * instance) can react to this without this module knowing it exists. + */ + /** @var Event $oEventService */ + $oEventService = Factory::service('Event'); + $oEventService->trigger( + Events::USER_DID_REMOVE_PASSKEY, + Events::getEventNamespace(), + [(int) $oPasskey->user_id] + ); } return $bResult;