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;