From ca7c9ac024c1cbd9b2dda12d1b47156ec6b93bc8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:06:42 +0100
Subject: [PATCH 01/18] fix: Resolve PHPStan level 1 errors and raise the level
Fixes the thirteen errors reported at level 1 and raises `.phpstan/config.neon`
from level 0 to level 1 so they stay fixed. Three of them were real:
- `User::create()` passed an undefined `$data` to `autoSaveExpandableFieldsExtract()`,
which takes `array &$aData` by reference; passing an undefined variable to a typed
by-reference parameter is a TypeError, so the method fatalled. It should have been
`$aData`, as in `Common\Model\Base::create()`.
- `User::update()` tested `$bPasswordUpdated`, which was never assigned, so the
remember-me cookie was never refreshed after a password change despite the comment
above it saying that is the intent. The variable is now set where the password is
changed.
- `mergeUpdateColumns()` read `$sColumn` after the loop it was assigned in, which is
undefined when a table maps no columns. Made explicit with `end()`, preserving the
existing behaviour, with a `@todo` recording that filtering on only the last mapped
column is wrong for a table which references the user more than once.
The rest are docblocks which claimed a non-nullable type from a `getById()` that can
return null, immediately above the `empty()` check which proves otherwise, plus a
redundant nested `empty()` and a `??` on a variable that cannot be defined.
`composer analyse` gets `--memory-limit=1G`; 256M is not enough to reach level 1.
Co-Authored-By: Claude Opus 5
---
.phpstan/config.neon | 2 +-
admin/controllers/Accounts.php | 1 +
admin/controllers/Merge.php | 2 +-
admin/controllers/Settings.php | 8 +++-----
composer.json | 2 +-
src/Model/User.php | 25 +++++++++++++++++++++----
src/Model/User/Group.php | 1 +
src/Model/User/Password.php | 4 ++--
src/Resource/User.php | 2 +-
9 files changed, 32 insertions(+), 15 deletions(-)
diff --git a/.phpstan/config.neon b/.phpstan/config.neon
index 9abefef5..89ab3a36 100644
--- a/.phpstan/config.neon
+++ b/.phpstan/config.neon
@@ -1,5 +1,5 @@
parameters:
- level: 0
+ level: 1
paths:
- ../helpers
- ../src
diff --git a/admin/controllers/Accounts.php b/admin/controllers/Accounts.php
index 1e01c180..fad5ccca 100755
--- a/admin/controllers/Accounts.php
+++ b/admin/controllers/Accounts.php
@@ -512,6 +512,7 @@ public function edit(): void
// --------------------------------------------------------------------------
+ /** @var \Nails\Auth\Resource\User|null $oUser */
$oUser = $oUserModel->getById($oUri->segment(5));
if (empty($oUser)) {
diff --git a/admin/controllers/Merge.php b/admin/controllers/Merge.php
index 320be808..bbf99e25 100644
--- a/admin/controllers/Merge.php
+++ b/admin/controllers/Merge.php
@@ -48,7 +48,7 @@ public static function announce(): Nav|array|null
->addAction('Merge Users');
}
- return $oNavGroup ?? null;
+ return null;
}
// --------------------------------------------------------------------------
diff --git a/admin/controllers/Settings.php b/admin/controllers/Settings.php
index 43828e49..60ab170f 100644
--- a/admin/controllers/Settings.php
+++ b/admin/controllers/Settings.php
@@ -219,11 +219,9 @@ public function index(): void
$bRollback = false;
- if (!empty($aSettings)) {
- if (!$oAppSettingService->set($aSettings, 'auth')) {
- $error = $oAppSettingService->lastError();
- $bRollback = true;
- }
+ if (!$oAppSettingService->set($aSettings, 'auth')) {
+ $error = $oAppSettingService->lastError();
+ $bRollback = true;
}
if (!empty($aSettingsEncrypted)) {
diff --git a/composer.json b/composer.json
index e986896a..a911d4f5 100644
--- a/composer.json
+++ b/composer.json
@@ -53,7 +53,7 @@
},
"scripts": {
"test": "./vendor/bin/phpunit",
- "analyse": "./vendor/bin/phpstan analyse -c .phpstan/config.neon --memory-limit=256M"
+ "analyse": "./vendor/bin/phpstan analyse -c .phpstan/config.neon --memory-limit=1G"
},
"autoload": {
"psr-4": {
diff --git a/src/Model/User.php b/src/Model/User.php
index 1647e2ea..a01948c3 100644
--- a/src/Model/User.php
+++ b/src/Model/User.php
@@ -1287,6 +1287,7 @@ public function update($iUserId = null, ?array $aData = null): bool
// --------------------------------------------------------------------------
// Update the password if it has been supplied
+ $bPasswordUpdated = false;
if (!empty($sNewPassword)) {
$bIsTemp = (bool) getFromArray('temp_pw', $aData);
if (!$oUserPasswordModel->change($iUserId, $sNewPassword, $bIsTemp)) {
@@ -1295,6 +1296,7 @@ public function update($iUserId = null, ?array $aData = null): bool
$oUserPasswordModel->lastError()
);
}
+ $bPasswordUpdated = true;
}
// --------------------------------------------------------------------------
@@ -1441,6 +1443,7 @@ protected function getUserId($iUserId = null)
public function setCacheUser($iUserId, $aData = [])
{
$this->unsetCacheUser($iUserId);
+ /** @var Resource\User|null $oUser */
$oUser = $this->getById($iUserId);
if (empty($oUser)) {
@@ -1496,7 +1499,9 @@ public function emailAdd(
$iUserId = empty($iUserId) ? $this->activeUser('id') : $iUserId;
$sEmail = trim(strtolower($sEmail));
- $oUser = $this->getById($iUserId);
+
+ /** @var Resource\User|null $oUser */
+ $oUser = $this->getById($iUserId);
if (empty($oUser)) {
$this->setError('Invalid User ID');
@@ -2251,7 +2256,7 @@ public function create(array $data = [], $bSendWelcome = true)
$aUserData['group_id'] = $data['group_id'];
}
- /** @var Resource\User\Group $oGroup */
+ /** @var Resource\User\Group|null $oGroup */
$oGroup = $oUserGroupModel->getById($aUserData['group_id']);
if (empty($oGroup)) {
@@ -2840,6 +2845,10 @@ protected function mergeUpdateColumns(array $aMap, int $iKeepId, array $aMergeId
foreach ($aMap as $sTable => $aColumns) {
+ if (empty($aColumns)) {
+ continue;
+ }
+
foreach ($aColumns as $sColumn) {
$oDb->set($sColumn, $iKeepId);
}
@@ -2849,12 +2858,20 @@ protected function mergeUpdateColumns(array $aMap, int $iKeepId, array $aMergeId
$oDb->set('is_primary', false);
}
- $oDb->where_in($sColumn, $aMergeIds);
+ /**
+ * @todo (Pablo 2026-09-10) - this filters on the last mapped column only,
+ * which is wrong for a table referencing the user more than once (e.g. both
+ * created_by and modified_by). Previously it relied on the loop variable
+ * leaking; made explicit here without changing the behaviour.
+ */
+ $sFilterColumn = end($aColumns);
+
+ $oDb->where_in($sFilterColumn, $aMergeIds);
if (!$oDb->update($sTable)) {
throw new MergeException(sprintf(
'Failed to migrate column "%s" in table "%s"',
- $sColumn,
+ $sFilterColumn,
$sTable
));
}
diff --git a/src/Model/User/Group.php b/src/Model/User/Group.php
index 91eb6b5e..48996f25 100644
--- a/src/Model/User/Group.php
+++ b/src/Model/User/Group.php
@@ -161,6 +161,7 @@ public function getDefaultGroupId()
*/
public function changeUserGroup(array $aUserIds, $iNewGroupId)
{
+ /** @var \Nails\Auth\Resource\User\Group|null $oGroup */
$oGroup = $this->getById($iNewGroupId);
if (empty($oGroup)) {
$this->setError('"' . $iNewGroupId . '" is not a valid group ID.');
diff --git a/src/Model/User/Password.php b/src/Model/User/Password.php
index 64823b6b..dbbacc1a 100644
--- a/src/Model/User/Password.php
+++ b/src/Model/User/Password.php
@@ -129,7 +129,7 @@ public function change(int $iUserId, string $sPassword, bool $bIsTemp = false)
// --------------------------------------------------------------------------
- /** @var Resource\User $oUser */
+ /** @var Resource\User|null $oUser */
$oUser = $oUserModel->getById($iUserId);
if (empty($oUser)) {
$this->setError('Invalid user ID.');
@@ -897,7 +897,7 @@ public function validateToken($sCode, $bGenerateNewPw)
/** @var User\Email $oUserModel */
$oUserEmailModel = Factory::model('UserEmail', Constants::MODULE_SLUG);
- /** @var Resource\User $oUser */
+ /** @var Resource\User|null $oUser */
$oUser = $oUserModel
->skipCache()
->getFirst([
diff --git a/src/Resource/User.php b/src/Resource/User.php
index dbb24860..f5752480 100644
--- a/src/Resource/User.php
+++ b/src/Resource/User.php
@@ -55,7 +55,7 @@ class User extends Entity
/** @var string */
public $remember_code;
- /** @var DateTime */
+ /** @var DateTime|null Null until the user's second login */
public $last_login;
/** @var DateTime */
From 0bdab2a45dbed7208cdb295c1bd1a43f16a89414 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:06:53 +0100
Subject: [PATCH 02/18] build: Require lbuchs/webauthn and register the passkey
helper
Adds the WebAuthn library which the passkey ceremonies are built on, along with
the extensions it needs: ext-openssl for key handling and signature verification,
ext-mbstring for its string handling. ext-sodium is only suggested, as it is
needed solely for authenticators which use Ed25519 keys.
Also registers the `passkey` helper so `passkeysEnabled()`, `loadPasskeyAssets()`
and the button helpers are available to app-overridden views.
Co-Authored-By: Claude Opus 5
---
composer.json | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index a911d4f5..cc407637 100644
--- a/composer.json
+++ b/composer.json
@@ -41,7 +41,10 @@
"hybridauth/hybridauth": "~3.0",
"sonata-project/google-authenticator": "~2.3.0",
"wikimedia/common-passwords": "^v0.4",
- "ext-json": "*"
+ "lbuchs/webauthn": "^2.2",
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "ext-mbstring": "*"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
@@ -49,7 +52,8 @@
"nails/module-queue": "dev-feature/pre-new-admin"
},
"suggest": {
- "nails/module-queue": "Processes user imports on a long-running worker, rather than in chunks on the cron."
+ "nails/module-queue": "Processes user imports on a long-running worker, rather than in chunks on the cron.",
+ "ext-sodium": "Allows passkeys which use Ed25519 (EdDSA) keys to be registered and verified."
},
"scripts": {
"test": "./vendor/bin/phpunit",
@@ -78,6 +82,7 @@
],
"helpers": [
"authUrls",
+ "passkey",
"user"
]
},
From 558b80d650d8301212750999044bbf1c717b5d64 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:05 +0100
Subject: [PATCH 03/18] feat: Add the user_passkey table
Stores one WebAuthn credential per row.
`credential_id` holds the base64url encoded raw ID and is unique, so a credential
cannot be registered twice. It is ascii rather than utf8mb4 because the spec allows
raw IDs up to 1023 bytes; at 1400 characters a utf8mb4 unique index would exceed
InnoDB's 3072 byte key limit.
`user_handle` stores the opaque handle presented to the authenticator at
registration, so verification can compare what the client sends against the value
that credential was actually created with. Keeping it per row means rotating
PRIVATE_KEY does not invalidate credentials which already exist.
`public_key` is plaintext PEM: a public key is public by definition, and encrypting
it would tie every passkey to PRIVATE_KEY rotation.
Co-Authored-By: Claude Opus 5
---
src/Database/Migration/Migration19.php | 69 ++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 src/Database/Migration/Migration19.php
diff --git a/src/Database/Migration/Migration19.php b/src/Database/Migration/Migration19.php
new file mode 100644
index 00000000..d89bcc14
--- /dev/null
+++ b/src/Database/Migration/Migration19.php
@@ -0,0 +1,69 @@
+query(
+ <<<'EOT'
+ CREATE TABLE `{{NAILS_DB_PREFIX}}user_passkey` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` int unsigned NOT NULL,
+ `label` varchar(100) NOT NULL DEFAULT '',
+ `credential_id` varchar(1400) CHARACTER SET ascii NOT NULL,
+ `public_key` text NOT NULL,
+ `sign_count` int unsigned NOT NULL DEFAULT 0,
+ `aaguid` char(36) NULL DEFAULT NULL,
+ `attestation_format` varchar(30) NULL DEFAULT NULL,
+ `transports` varchar(255) NULL DEFAULT NULL,
+ `is_discoverable` tinyint(1) unsigned NULL DEFAULT NULL,
+ `is_backup_eligible` tinyint(1) unsigned NOT NULL DEFAULT 0,
+ `is_backed_up` tinyint(1) unsigned NOT NULL DEFAULT 0,
+ `user_handle` varchar(64) CHARACTER SET ascii NOT NULL,
+ `last_used` datetime NULL DEFAULT NULL,
+ `last_used_ip` varchar(45) NULL DEFAULT NULL,
+ `created` datetime NOT NULL,
+ `created_by` int unsigned NULL DEFAULT NULL,
+ `modified` datetime NOT NULL,
+ `modified_by` int unsigned NULL DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `credential_id` (`credential_id`),
+ KEY `user_id` (`user_id`),
+ KEY `created_by` (`created_by`),
+ KEY `modified_by` (`modified_by`),
+ CONSTRAINT `{{NAILS_DB_PREFIX}}user_passkey_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `{{NAILS_DB_PREFIX}}user_passkey_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL,
+ CONSTRAINT `{{NAILS_DB_PREFIX}}user_passkey_ibfk_3` FOREIGN KEY (`modified_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ EOT
+ );
+ }
+}
From 356a5069cadd70441332dbf37f856f44a248815e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:05 +0100
Subject: [PATCH 04/18] feat: Add the passkey model, resource and exceptions
The model provides the lookups the ceremonies need: by user, by credential ID, a
count for the adoption nudge, and recording use after a successful assertion.
The resource types the row and decodes the transports JSON.
Every exception extends PasskeyException, so a caller can catch one type to handle
any failure in a ceremony, or catch the specific subclasses when it needs to tell a
stale challenge from a bad signature.
Co-Authored-By: Claude Opus 5
---
services/services.php | 21 ++++
src/Exception/Passkey/ChallengeException.php | 14 +++
.../Passkey/CredentialExistsException.php | 14 +++
.../Passkey/InvalidResponseException.php | 14 +++
src/Exception/Passkey/NotEnabledException.php | 14 +++
.../Passkey/OriginNotAllowedException.php | 14 +++
src/Exception/Passkey/PasskeyException.php | 17 +++
.../Passkey/UnknownCredentialException.php | 14 +++
.../Passkey/VerificationFailedException.php | 14 +++
src/Model/User/Passkey.php | 111 ++++++++++++++++++
src/Resource/User/Passkey.php | 106 +++++++++++++++++
11 files changed, 353 insertions(+)
create mode 100644 src/Exception/Passkey/ChallengeException.php
create mode 100644 src/Exception/Passkey/CredentialExistsException.php
create mode 100644 src/Exception/Passkey/InvalidResponseException.php
create mode 100644 src/Exception/Passkey/NotEnabledException.php
create mode 100644 src/Exception/Passkey/OriginNotAllowedException.php
create mode 100644 src/Exception/Passkey/PasskeyException.php
create mode 100644 src/Exception/Passkey/UnknownCredentialException.php
create mode 100644 src/Exception/Passkey/VerificationFailedException.php
create mode 100644 src/Model/User/Passkey.php
create mode 100644 src/Resource/User/Passkey.php
diff --git a/services/services.php b/services/services.php
index e313bb46..587d5ed0 100644
--- a/services/services.php
+++ b/services/services.php
@@ -14,6 +14,13 @@
return new Service\Authentication();
}
},
+ 'Passkey' => function (): Service\Passkey {
+ if (class_exists('\App\Auth\Service\Passkey')) {
+ return new \App\Auth\Service\Passkey();
+ } else {
+ return new Service\Passkey();
+ }
+ },
'Session' => function (): Service\Session {
if (class_exists('\App\Auth\Service\Session')) {
return new \App\Auth\Service\Session();
@@ -135,6 +142,13 @@
return new Model\User\Import\Item();
}
},
+ 'UserPasskey' => function (): Model\User\Passkey {
+ if (class_exists('\App\Auth\Model\User\Passkey')) {
+ return new \App\Auth\Model\User\Passkey();
+ } else {
+ return new Model\User\Passkey();
+ }
+ },
'UserPassword' => function (): Model\User\Password {
// @todo (Pablo 2025-07-15) - this should be a service
if (class_exists('\App\Auth\Model\User\Password')) {
@@ -274,6 +288,13 @@
return new Resource\User\Import\Item($resource, $model);
}
},
+ 'UserPasskey' => function ($resource, $model): Resource\User\Passkey {
+ if (class_exists('\App\Auth\Resource\User\Passkey')) {
+ return new \App\Auth\Resource\User\Passkey($resource, $model);
+ } else {
+ return new Resource\User\Passkey($resource, $model);
+ }
+ },
'UserPasswordHistory' => function ($resource, $model): Resource\User\Password\History {
if (class_exists('\App\Auth\Resource\User\Password\History')) {
return new \App\Auth\Resource\User\Password\History($resource, $model);
diff --git a/src/Exception/Passkey/ChallengeException.php b/src/Exception/Passkey/ChallengeException.php
new file mode 100644
index 00000000..38224ab2
--- /dev/null
+++ b/src/Exception/Passkey/ChallengeException.php
@@ -0,0 +1,14 @@
+hasOne('user', 'User', Constants::MODULE_SLUG);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns all the passkeys registered by a user, oldest first
+ *
+ * @return Resource\User\Passkey[]
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getByUserId(int $iUserId): array
+ {
+ /** @var Resource\User\Passkey[] $aPasskeys */
+ $aPasskeys = $this->getAll([
+ new Where('user_id', $iUserId),
+ ]);
+
+ return $aPasskeys;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns a passkey by its base64url encoded credential ID
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function getByCredentialId(string $sCredentialId): ?Resource\User\Passkey
+ {
+ if ($sCredentialId === '') {
+ return null;
+ }
+
+ /** @var Resource\User\Passkey|null $oPasskey */
+ $oPasskey = $this->getAll([
+ new Where('credential_id', $sCredentialId),
+ ])[0] ?? null;
+
+ return $oPasskey;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns how many passkeys a user has registered
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function countForUser(int $iUserId): int
+ {
+ return $this->countAll([
+ new Where('user_id', $iUserId),
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Records a successful use of a passkey
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function recordUse(int $iId, int $iSignCount, string $sIp): bool
+ {
+ /** @var \DateTime $oNow */
+ $oNow = Factory::factory('DateTime');
+
+ return $this->update($iId, [
+ 'sign_count' => $iSignCount,
+ 'last_used' => $oNow->format('Y-m-d H:i:s'),
+ 'last_used_ip' => $sIp ?: null,
+ ]);
+ }
+}
diff --git a/src/Resource/User/Passkey.php b/src/Resource/User/Passkey.php
new file mode 100644
index 00000000..f6232728
--- /dev/null
+++ b/src/Resource/User/Passkey.php
@@ -0,0 +1,106 @@
+user) && !empty($this->user_id)) {
+
+ /** @var \Nails\Auth\Model\User $oModel */
+ $oModel = Factory::model('User', Constants::MODULE_SLUG);
+ /** @var User|null $oUser */
+ $oUser = $oModel->getById($this->user_id);
+
+ $this->user = $oUser;
+ }
+
+ return $this->user;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the transports the authenticator reported at registration
+ *
+ * @return string[]
+ */
+ public function getTransports(): array
+ {
+ if (empty($this->transports)) {
+ return [];
+ }
+
+ $aTransports = json_decode($this->transports, true);
+
+ return is_array($aTransports)
+ ? array_values(array_filter($aTransports, 'is_string'))
+ : [];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the credential's public key in PEM format
+ */
+ public function getPublicKeyPem(): string
+ {
+ return $this->public_key;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Names the model of authenticator this passkey lives in, if it is a known one
+ *
+ * @throws FactoryException
+ */
+ public function getAuthenticatorName(): ?string
+ {
+ /** @var Service $oService */
+ $oService = Factory::service('Passkey', Constants::MODULE_SLUG);
+
+ return $oService->getAuthenticatorName($this->aaguid);
+ }
+
+}
From 77c31a9495b911df9f7c64ae75afca930a5b4a0d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:14 +0100
Subject: [PATCH 05/18] feat: Add the Passkey service
Holds the WebAuthn logic: configuration, the two ceremonies, and the session
challenge store.
Every call into lbuchs is made from getWebAuthn() and the build/verify methods, so
a change of library is confined to this file. getWebAuthn() deliberately returns a
new instance each time, because the library mints and caches one challenge per
instance and sharing one would re-issue a spent challenge.
The build/verify methods touch neither the database nor the session, so they can be
tested directly; the orchestration methods above them add storage and the challenge
round trip.
Two checks are ours rather than the library's. assertOriginAllowed() matches the
whole origin exactly, where the library matches only a suffix of the host, which a
lookalike domain would satisfy. And verifyAuthentication() compares the client's
user handle against the stored one, because the library does not read it back.
The user handle is an HMAC of the user's ID rather than a stored column, so no
change to the `user` table is needed.
Co-Authored-By: Claude Opus 5
---
src/Service/Passkey.php | 1235 +++++++++++++++++++++++++++++++++++++++
1 file changed, 1235 insertions(+)
create mode 100644 src/Service/Passkey.php
diff --git a/src/Service/Passkey.php b/src/Service/Passkey.php
new file mode 100644
index 00000000..884598e8
--- /dev/null
+++ b/src/Service/Passkey.php
@@ -0,0 +1,1235 @@
+
+ */
+ const AUTHENTICATORS = [
+ // Verified against a live registration
+ 'bada5566-a7aa-401f-bd96-45619a55120d' => '1Password',
+ // From the community AAGUID register
+ 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd' => 'iCloud Keychain',
+ 'adce0002-35bc-c60a-648b-0b25f1f05503' => 'Chrome on Mac',
+ 'ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4' => 'Google Password Manager',
+ 'd548826e-79b4-db40-a3d8-11116f7e8349' => 'Bitwarden',
+ '531126d6-e717-415c-9320-3d9aa6981239' => 'Dashlane',
+ '08987058-cadc-4b81-b6e1-30de50dcbe96' => 'Windows Hello',
+ '9ddd1817-af5a-4672-a2b9-3e3dd95000a9' => 'Windows Hello',
+ '6028b017-b1d4-4c02-b4b3-afcdafc96bb2' => 'Windows Hello',
+ 'cb69481e-8ff7-4039-93ec-0a2729a154a8' => 'YubiKey 5',
+ 'ee882879-721c-4913-9775-3dfcce97072a' => 'YubiKey 5',
+ 'fa2b99dc-9e39-4257-8f92-4a30d23c4118' => 'YubiKey 5 NFC',
+ '2fc0579f-8113-47ea-b116-bb5a8db9202a' => 'YubiKey 5 NFC',
+ ];
+
+ /**
+ * The cookie which suppresses the post-login nudge
+ *
+ * A cookie rather than user meta because the capability being nudged towards
+ * belongs to the browser, not to the account.
+ */
+ const COOKIE_NUDGE = 'nails-passkey-nudge';
+ const COOKIE_NUDGE_TTL = 31536000;
+
+ /**
+ * Set once this session has been offered a passkey, so it is only offered once
+ */
+ const SESSION_KEY_NUDGED = 'auth-passkey-nudged';
+
+ /**
+ * Where to send the user once they have answered the nudge
+ */
+ const SESSION_KEY_NUDGE_RETURN = 'auth-passkey-nudge-return';
+
+ // --------------------------------------------------------------------------
+ // Configuration
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether passkeys are available to this app
+ *
+ * @throws FactoryException
+ */
+ public function isEnabled(): bool
+ {
+ return (bool) appSetting(static::SETTING_ENABLED, static::SETTING_GROUP)
+ && extension_loaded('openssl');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Throws if passkeys are not enabled
+ *
+ * @throws FactoryException
+ * @throws NotEnabledException
+ */
+ public function assertEnabled(): void
+ {
+ if (!$this->isEnabled()) {
+ throw new NotEnabledException('Passkeys are not enabled.');
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the Relying Party ID: the config override, else the host of BASE_URL
+ *
+ * A single host per app is assumed; the RP ID must be the site's registrable
+ * domain or a suffix of it, and cannot include a scheme, port, or path.
+ */
+ public function getRpId(): string
+ {
+ $sConfigured = Config::get(static::CONFIG_RP_ID);
+ if (is_string($sConfigured) && trim($sConfigured) !== '') {
+ return strtolower(trim($sConfigured));
+ }
+
+ return $this->extractHost((string) Config::get('BASE_URL')) ?? 'localhost';
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the Relying Party name shown by the authenticator
+ */
+ public function getRpName(): string
+ {
+ $sAppName = Config::get('APP_NAME');
+
+ return is_string($sAppName) && trim($sAppName) !== ''
+ ? trim($sAppName)
+ : $this->getRpId();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns every origin a ceremony may legitimately be performed from
+ *
+ * @return string[]
+ */
+ public function getAllowedOrigins(): array
+ {
+ $aOrigins = [
+ $this->extractOrigin((string) Config::get('BASE_URL')),
+ $this->extractOrigin((string) Config::get('SECURE_BASE_URL')),
+ ];
+
+ $mConfigured = Config::get(static::CONFIG_ALLOWED_ORIGINS);
+ if (is_string($mConfigured)) {
+ $mConfigured = [$mConfigured];
+ }
+
+ if (is_array($mConfigured)) {
+ foreach ($mConfigured as $mOrigin) {
+ if (is_string($mOrigin)) {
+ $aOrigins[] = $this->extractOrigin($mOrigin);
+ }
+ }
+ }
+
+ return array_values(array_unique(array_filter($aOrigins)));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Rejects an origin which is not one of the app's own
+ *
+ * The library performs a suffix match on the host alone; this is an exact match
+ * on the whole origin, so a lookalike host or a downgraded scheme is refused.
+ *
+ * @throws OriginNotAllowedException
+ */
+ public function assertOriginAllowed(string $sOrigin): void
+ {
+ $sNormalised = $this->extractOrigin($sOrigin);
+
+ if ($sNormalised === null || !in_array($sNormalised, $this->getAllowedOrigins(), true)) {
+ throw new OriginNotAllowedException(
+ sprintf('"%s" is not a permitted origin.', $sOrigin)
+ );
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Derives the opaque, stable user handle presented to the authenticator
+ *
+ * Deriving it rather than storing it keeps the `user` table untouched; see J3.
+ * The handle is written to each passkey row at registration, so verification
+ * still succeeds for existing credentials after a PRIVATE_KEY rotation.
+ */
+ public function deriveUserHandle(Resource\User $oUser): string
+ {
+ return $this->base64UrlEncode(
+ hash_hmac(
+ 'sha256',
+ static::USER_HANDLE_PREFIX . $oUser->id,
+ (string) Config::get('PRIVATE_KEY'),
+ true
+ )
+ );
+ }
+
+ /**
+ * Names the model of authenticator a passkey lives in, if it is a known one
+ *
+ * @return string|null Null when the authenticator did not say, or is not listed
+ */
+ public function getAuthenticatorName(?string $sAaguid): ?string
+ {
+ if (empty($sAaguid)) {
+ return null;
+ }
+
+ $aConfigured = Config::get(static::CONFIG_AUTHENTICATORS);
+ $aKnown = array_merge(
+ static::AUTHENTICATORS,
+ is_array($aConfigured) ? $aConfigured : []
+ );
+
+ $sName = $aKnown[strtolower($sAaguid)] ?? null;
+
+ return is_string($sName) && $sName !== '' ? $sName : null;
+ }
+
+
+ // --------------------------------------------------------------------------
+ // Ceremony construction and verification; no database, no session
+ // --------------------------------------------------------------------------
+
+ /**
+ * The single point at which the WebAuthn library is constructed
+ *
+ * A new instance is returned every time: the library mints and caches one
+ * challenge per instance, so sharing one would re-issue a spent challenge.
+ *
+ * @throws PasskeyException
+ */
+ public function getWebAuthn(): WebAuthn
+ {
+ try {
+
+ return new WebAuthn(
+ $this->getRpName(),
+ $this->getRpId(),
+ [static::ATTESTATION_FORMAT_NONE],
+ true
+ );
+
+ } catch (WebAuthnException $e) {
+ throw new PasskeyException(
+ 'Failed to initialise WebAuthn: ' . $e->getMessage(),
+ $e->getCode(),
+ $e
+ );
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Builds the options for a registration ceremony
+ *
+ * @param string[] $aExcludeCredentialIds base64url credential IDs already registered
+ *
+ * @return stdClass&object{options: stdClass, challenge: string}
+ * @throws PasskeyException
+ */
+ public function buildRegistrationOptions(Resource\User $oUser, array $aExcludeCredentialIds = []): stdClass
+ {
+ $oWebAuthn = $this->getWebAuthn();
+
+ try {
+
+ $oArgs = $oWebAuthn->getCreateArgs(
+ $this->base64UrlDecode($this->deriveUserHandle($oUser)),
+ $this->getUserName($oUser),
+ $this->getUserDisplayName($oUser),
+ static::TIMEOUT,
+ 'preferred',
+ static::UV_PREFERRED,
+ null,
+ array_map(
+ fn(string $sId): string => $this->base64UrlDecode($sId),
+ array_values($aExcludeCredentialIds)
+ )
+ );
+
+ } catch (WebAuthnException $e) {
+ throw new PasskeyException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ // Ask the browser whether the credential ended up discoverable
+ $oArgs->publicKey->extensions->credProps = true;
+
+ return (object) [
+ 'options' => $oArgs->publicKey,
+ 'challenge' => $this->base64UrlEncode($oWebAuthn->getChallenge()->getBinaryString()),
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Builds the options for an authentication ceremony
+ *
+ * An empty allow-list produces a discoverable ("passwordless") request.
+ *
+ * @param string[] $aAllowCredentialIds base64url credential IDs
+ *
+ * @return stdClass&object{options: stdClass, challenge: string}
+ * @throws PasskeyException
+ */
+ public function buildAuthenticationOptions(
+ array $aAllowCredentialIds = [],
+ string $sUserVerification = self::UV_REQUIRED
+ ): stdClass {
+
+ $oWebAuthn = $this->getWebAuthn();
+
+ try {
+
+ $oArgs = $oWebAuthn->getGetArgs(
+ array_map(
+ fn(string $sId): string => $this->base64UrlDecode($sId),
+ array_values($aAllowCredentialIds)
+ ),
+ static::TIMEOUT,
+ true,
+ true,
+ true,
+ true,
+ true,
+ $sUserVerification
+ );
+
+ } catch (WebAuthnException $e) {
+ throw new PasskeyException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ return (object) [
+ 'options' => $oArgs->publicKey,
+ 'challenge' => $this->base64UrlEncode($oWebAuthn->getChallenge()->getBinaryString()),
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Verifies a registration response and returns the data to store
+ *
+ * @param array $aClientResponse PublicKeyCredential.toJSON() output
+ *
+ * @return stdClass&object{credential_id: string, public_key: string, sign_count: int, aaguid: string|null,
+ * attestation_format: string|null, transports: string[], is_discoverable: bool|null,
+ * is_backup_eligible: bool, is_backed_up: bool, user_present: bool,
+ * user_verified: bool}
+ * @throws InvalidResponseException
+ * @throws OriginNotAllowedException
+ * @throws PasskeyException
+ * @throws VerificationFailedException
+ */
+ public function verifyRegistration(array $aClientResponse, string $sChallenge): stdClass
+ {
+ $aResponse = $this->extractResponse($aClientResponse);
+ $sClientDataJson = $this->requireBinary($aResponse, 'clientDataJSON');
+ $sAttestationObject = $this->requireBinary($aResponse, 'attestationObject');
+
+ $this->assertOriginAllowed(
+ $this->readOriginFromClientData($sClientDataJson)
+ );
+
+ $oWebAuthn = $this->getWebAuthn();
+
+ try {
+
+ $oData = $oWebAuthn->processCreate(
+ $sClientDataJson,
+ $sAttestationObject,
+ $this->base64UrlDecode($sChallenge),
+ false,
+ true,
+ false,
+ false
+ );
+
+ } catch (WebAuthnException $e) {
+ throw new VerificationFailedException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ return (object) [
+ 'credential_id' => $this->base64UrlEncode($oData->credentialId),
+ 'public_key' => (string) $oData->credentialPublicKey,
+ 'sign_count' => (int) ($oData->signatureCounter ?? 0),
+ 'aaguid' => $this->formatAaguid((string) $oData->AAGUID),
+ 'attestation_format' => $oData->attestationFormat ? (string) $oData->attestationFormat : null,
+ 'transports' => $this->extractTransports($aResponse),
+ 'is_discoverable' => $this->extractIsDiscoverable($aClientResponse),
+ 'is_backup_eligible' => (bool) $oData->isBackupEligible,
+ 'is_backed_up' => (bool) $oData->isBackedUp,
+ 'user_present' => (bool) $oData->userPresent,
+ 'user_verified' => (bool) $oData->userVerified,
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Verifies an authentication response and returns the authenticator's sign count
+ *
+ * Returns 0 for authenticators which do not maintain a counter; the caller must
+ * only persist a count which has grown.
+ *
+ * @param array $aClientResponse PublicKeyCredential.toJSON() output
+ *
+ * @throws InvalidResponseException
+ * @throws OriginNotAllowedException
+ * @throws PasskeyException
+ * @throws VerificationFailedException
+ */
+ public function verifyAuthentication(
+ array $aClientResponse,
+ string $sChallenge,
+ string $sPublicKeyPem,
+ int $iPrevSignCount,
+ string $sExpectedUserHandle,
+ bool $bRequireUserVerification
+ ): int {
+
+ $aResponse = $this->extractResponse($aClientResponse);
+ $sClientDataJson = $this->requireBinary($aResponse, 'clientDataJSON');
+ $sAuthenticatorData = $this->requireBinary($aResponse, 'authenticatorData');
+ $sSignature = $this->requireBinary($aResponse, 'signature');
+
+ $this->assertOriginAllowed(
+ $this->readOriginFromClientData($sClientDataJson)
+ );
+
+ /**
+ * The library does not read the user handle back, so it is checked here: a
+ * handle which is present but belongs to somebody else must not authenticate.
+ */
+ $sUserHandle = $aResponse['userHandle'] ?? null;
+ if (is_string($sUserHandle) && $sUserHandle !== '') {
+ if (!hash_equals($sExpectedUserHandle, $sUserHandle)) {
+ throw new VerificationFailedException('The credential belongs to a different user.');
+ }
+ }
+
+ $oWebAuthn = $this->getWebAuthn();
+
+ try {
+
+ $oWebAuthn->processGet(
+ $sClientDataJson,
+ $sAuthenticatorData,
+ $sSignature,
+ $sPublicKeyPem,
+ $this->base64UrlDecode($sChallenge),
+ $iPrevSignCount,
+ $bRequireUserVerification,
+ true
+ );
+
+ } catch (WebAuthnException $e) {
+ throw new VerificationFailedException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ return $oWebAuthn->getSignatureCounter() ?? 0;
+ }
+
+ // --------------------------------------------------------------------------
+ // Orchestration; these touch the database and the session
+ // --------------------------------------------------------------------------
+
+ /**
+ * Mints registration options for a user, excluding the passkeys they already have
+ *
+ * @return stdClass&object{options: stdClass, challenge: string}
+ * @throws FactoryException
+ * @throws ModelException
+ * @throws NotEnabledException
+ * @throws PasskeyException
+ */
+ public function createRegistrationOptions(Resource\User $oUser): stdClass
+ {
+ $this->assertEnabled();
+
+ $aExisting = array_map(
+ fn(Resource\User\Passkey $oPasskey): string => $oPasskey->credential_id,
+ $this->getModel()->getByUserId((int) $oUser->id)
+ );
+
+ $oOptions = $this->buildRegistrationOptions($oUser, $aExisting);
+
+ $this->rememberChallenge(
+ static::PURPOSE_REGISTRATION,
+ $oOptions->challenge,
+ (int) $oUser->id
+ );
+
+ return $oOptions;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Verifies a registration response and stores the resulting passkey
+ *
+ * @param array $aClientResponse
+ *
+ * @throws CredentialExistsException
+ * @throws FactoryException
+ * @throws InvalidResponseException
+ * @throws ModelException
+ * @throws NotEnabledException
+ * @throws OriginNotAllowedException
+ * @throws PasskeyException
+ * @throws VerificationFailedException
+ */
+ public function completeRegistration(
+ Resource\User $oUser,
+ array $aClientResponse,
+ string $sChallenge,
+ ?string $sLabel = null
+ ): Resource\User\Passkey {
+
+ $this->assertEnabled();
+
+ $oModel = $this->getModel();
+ $oResult = $this->verifyRegistration($aClientResponse, $sChallenge);
+
+ if ($oModel->getByCredentialId($oResult->credential_id)) {
+ throw new CredentialExistsException('This passkey is already registered.');
+ }
+
+ /** @var Resource\User\Passkey|false $oPasskey */
+ $oPasskey = $oModel->create(
+ [
+ 'user_id' => (int) $oUser->id,
+ 'label' => $this->normaliseLabel(
+ trim((string) $sLabel) !== ''
+ ? $sLabel
+ // Naming it after the authenticator beats a list of "Passkey"
+ : $this->getAuthenticatorName($oResult->aaguid)
+ ),
+ 'credential_id' => $oResult->credential_id,
+ 'public_key' => $oResult->public_key,
+ 'sign_count' => $oResult->sign_count,
+ 'aaguid' => $oResult->aaguid,
+ 'attestation_format' => $oResult->attestation_format,
+ 'transports' => $oResult->transports ? json_encode($oResult->transports) : null,
+ 'is_discoverable' => $oResult->is_discoverable,
+ 'is_backup_eligible' => $oResult->is_backup_eligible,
+ 'is_backed_up' => $oResult->is_backed_up,
+ 'user_handle' => $this->deriveUserHandle($oUser),
+ ],
+ true
+ );
+
+ if (empty($oPasskey)) {
+ throw new PasskeyException('Failed to save the passkey.');
+ }
+
+ createUserEvent(
+ 'did_add_passkey',
+ ['passkey_id' => $oPasskey->id, 'label' => $oPasskey->label],
+ null,
+ (int) $oUser->id
+ );
+
+ return $oPasskey;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Mints authentication options
+ *
+ * Passing no user produces a discoverable request, which is what the passwordless
+ * button and the conditional-UI autofill both use.
+ *
+ * @return stdClass&object{options: stdClass, challenge: string}
+ * @throws FactoryException
+ * @throws ModelException
+ * @throws NotEnabledException
+ * @throws PasskeyException
+ */
+ public function createAuthenticationOptions(
+ ?Resource\User $oUser = null,
+ string $sUserVerification = self::UV_REQUIRED
+ ): stdClass {
+
+ $this->assertEnabled();
+
+ $aAllow = $oUser
+ ? array_map(
+ fn(Resource\User\Passkey $oPasskey): string => $oPasskey->credential_id,
+ $this->getModel()->getByUserId((int) $oUser->id)
+ )
+ : [];
+
+ $oOptions = $this->buildAuthenticationOptions($aAllow, $sUserVerification);
+
+ $this->rememberChallenge(
+ static::PURPOSE_AUTHENTICATION,
+ $oOptions->challenge,
+ $oUser ? (int) $oUser->id : null
+ );
+
+ return $oOptions;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Looks up the passkey an assertion refers to
+ *
+ * @param array $aClientResponse
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function findByAssertion(array $aClientResponse): ?Resource\User\Passkey
+ {
+ $sId = $aClientResponse['rawId'] ?? $aClientResponse['id'] ?? null;
+
+ return is_string($sId)
+ ? $this->getModel()->getByCredentialId($sId)
+ : null;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Verifies an assertion against a stored passkey
+ *
+ * @param array $aClientResponse
+ *
+ * @throws FactoryException
+ * @throws InvalidResponseException
+ * @throws ModelException
+ * @throws NotEnabledException
+ * @throws OriginNotAllowedException
+ * @throws PasskeyException
+ * @throws UnknownCredentialException
+ * @throws VerificationFailedException
+ */
+ public function completeAuthentication(
+ array $aClientResponse,
+ string $sChallenge,
+ ?Resource\User $oRestrictToUser = null,
+ bool $bRequireUserVerification = true
+ ): Resource\User\Passkey {
+
+ $this->assertEnabled();
+
+ $oPasskey = $this->findByAssertion($aClientResponse);
+
+ if (empty($oPasskey)) {
+ throw new UnknownCredentialException('Unrecognised passkey.');
+
+ } elseif ($oRestrictToUser && (int) $oPasskey->user_id !== (int) $oRestrictToUser->id) {
+ throw new UnknownCredentialException('Unrecognised passkey.');
+ }
+
+ $iSignCount = $this->verifyAuthentication(
+ $aClientResponse,
+ $sChallenge,
+ $oPasskey->getPublicKeyPem(),
+ $oPasskey->sign_count,
+ $oPasskey->user_handle,
+ $bRequireUserVerification
+ );
+
+ /** @var Input $oInput */
+ $oInput = Factory::service('Input');
+
+ /**
+ * Authenticators which do not keep a counter always report zero; writing that
+ * back would be indistinguishable from a clone, so only a count which grew is
+ * persisted. The timestamp is recorded either way.
+ */
+ $this->getModel()->recordUse(
+ (int) $oPasskey->id,
+ max($iSignCount, $oPasskey->sign_count),
+ (string) $oInput->ipAddress()
+ );
+
+ return $oPasskey;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Renames a passkey
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function rename(Resource\User\Passkey $oPasskey, string $sLabel): bool
+ {
+ return $this->getModel()->update((int) $oPasskey->id, [
+ 'label' => $this->normaliseLabel($sLabel),
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Removes a passkey
+ *
+ * @param array $aEventData additional context to log against the event
+ *
+ * @throws FactoryException
+ * @throws ModelException
+ */
+ public function revoke(Resource\User\Passkey $oPasskey, array $aEventData = []): bool
+ {
+ $bResult = $this->getModel()->delete((int) $oPasskey->id);
+
+ if ($bResult) {
+ createUserEvent(
+ 'did_remove_passkey',
+ array_merge(
+ ['passkey_id' => $oPasskey->id, 'label' => $oPasskey->label],
+ $aEventData
+ ),
+ null,
+ (int) $oPasskey->user_id
+ );
+ }
+
+ return $bResult;
+ }
+
+ // --------------------------------------------------------------------------
+ // Adoption nudge
+ // --------------------------------------------------------------------------
+
+ /**
+ * Stops this browser being nudged again
+ *
+ * @throws FactoryException
+ */
+ public function setNudgeDismissed(): void
+ {
+ /** @var Cookie $oCookie */
+ $oCookie = Factory::service('Cookie');
+ $oCookie->write(
+ static::COOKIE_NUDGE,
+ 'dismissed',
+ static::COOKIE_NUDGE_TTL,
+ '/',
+ '',
+ Functions::isPageSecure(),
+ true,
+ 'Lax'
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether this browser has already been nudged away
+ *
+ * @throws FactoryException
+ */
+ public function isNudgeDismissed(): bool
+ {
+ /** @var Cookie $oCookie */
+ $oCookie = Factory::service('Cookie');
+
+ return !empty($oCookie->read(static::COOKIE_NUDGE));
+ }
+
+ /**
+ * Records that this session has been offered a passkey, and where to return to
+ *
+ * @throws FactoryException
+ */
+ public function markNudged(string $sReturnTo): void
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+ $oSession
+ ->setUserData(static::SESSION_KEY_NUDGED, true)
+ ->setUserData(static::SESSION_KEY_NUDGE_RETURN, $sReturnTo);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether this session has already been offered a passkey
+ *
+ * @throws FactoryException
+ */
+ public function hasBeenNudged(): bool
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+
+ return !empty($oSession->getUserData(static::SESSION_KEY_NUDGED));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reads, and forgets, where the nudge should return the user to
+ *
+ * @throws FactoryException
+ */
+ public function consumeNudgeReturn(): ?string
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+
+ $mReturnTo = $oSession->getUserData(static::SESSION_KEY_NUDGE_RETURN);
+ $oSession->unsetUserData(static::SESSION_KEY_NUDGE_RETURN);
+
+ return is_string($mReturnTo) && $mReturnTo !== '' ? $mReturnTo : null;
+ }
+
+ // --------------------------------------------------------------------------
+ // Challenge store
+ // --------------------------------------------------------------------------
+
+ /**
+ * Remembers the challenge for the ceremony's second request
+ *
+ * Ordinary session data rather than flash data: the ceremony spans two requests
+ * and the flash would be gone by the time the response comes back.
+ *
+ * @throws FactoryException
+ */
+ public function rememberChallenge(string $sPurpose, string $sChallenge, ?int $iUserId): void
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+ $oSession->setUserData(static::SESSION_KEY_CHALLENGE, (object) [
+ 'purpose' => $sPurpose,
+ 'challenge' => $sChallenge,
+ 'user_id' => $iUserId,
+ 'at' => time(),
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Spends the stored challenge, removing it whether or not it turns out to be valid
+ *
+ * @throws ChallengeException
+ * @throws FactoryException
+ */
+ public function consumeChallenge(string $sPurpose, ?int $iUserId): string
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+
+ $oStored = $oSession->getUserData(static::SESSION_KEY_CHALLENGE);
+ $oSession->unsetUserData(static::SESSION_KEY_CHALLENGE);
+
+ if (!is_object($oStored) || !isset($oStored->challenge, $oStored->purpose, $oStored->at)) {
+ throw new ChallengeException('No passkey challenge is in progress; please try again.');
+
+ } elseif ($oStored->purpose !== $sPurpose) {
+ throw new ChallengeException('The passkey challenge was issued for something else.');
+
+ } elseif (($oStored->user_id ?? null) !== $iUserId) {
+ throw new ChallengeException('The passkey challenge was issued for a different user.');
+
+ } elseif ((time() - (int) $oStored->at) > static::CHALLENGE_TTL) {
+ throw new ChallengeException('The passkey challenge has expired; please try again.');
+ }
+
+ return (string) $oStored->challenge;
+ }
+
+ // --------------------------------------------------------------------------
+ // Encoding
+ // --------------------------------------------------------------------------
+
+ /**
+ * Encodes binary as base64url, without padding
+ */
+ public function base64UrlEncode(string $sBinary): string
+ {
+ return rtrim(strtr(base64_encode($sBinary), '+/', '-_'), '=');
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Decodes base64url, tolerating missing padding
+ */
+ public function base64UrlDecode(string $sEncoded): string
+ {
+ return (string) base64_decode(
+ str_pad(strtr($sEncoded, '-_', '+/'), (int) (ceil(strlen($sEncoded) / 4) * 4), '='),
+ false
+ );
+ }
+
+ // --------------------------------------------------------------------------
+ // Internals
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ protected function getModel(): PasskeyModel
+ {
+ /** @var PasskeyModel $oModel */
+ $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+
+ return $oModel;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Pulls the `response` object out of a client payload
+ *
+ * @param array $aClientResponse
+ *
+ * @return array
+ * @throws InvalidResponseException
+ */
+ protected function extractResponse(array $aClientResponse): array
+ {
+ $mResponse = $aClientResponse['response'] ?? null;
+
+ if (is_object($mResponse)) {
+ $mResponse = (array) $mResponse;
+ }
+
+ if (!is_array($mResponse)) {
+ throw new InvalidResponseException('The passkey response is missing or malformed.');
+ }
+
+ return $mResponse;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reads and decodes a required base64url field
+ *
+ * @param array $aResponse
+ *
+ * @throws InvalidResponseException
+ */
+ protected function requireBinary(array $aResponse, string $sKey): string
+ {
+ $mValue = $aResponse[$sKey] ?? null;
+
+ if (!is_string($mValue) || $mValue === '') {
+ throw new InvalidResponseException(
+ sprintf('The passkey response is missing "%s".', $sKey)
+ );
+ }
+
+ $sDecoded = $this->base64UrlDecode($mValue);
+
+ if ($sDecoded === '') {
+ throw new InvalidResponseException(
+ sprintf('The passkey response field "%s" is not valid base64url.', $sKey)
+ );
+ }
+
+ return $sDecoded;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reads the origin out of the client data
+ *
+ * @throws InvalidResponseException
+ */
+ protected function readOriginFromClientData(string $sClientDataJson): string
+ {
+ $mClientData = json_decode($sClientDataJson);
+
+ if (!is_object($mClientData) || !isset($mClientData->origin) || !is_string($mClientData->origin)) {
+ throw new InvalidResponseException('The passkey client data is malformed.');
+ }
+
+ return $mClientData->origin;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @param array $aResponse
+ *
+ * @return string[]
+ */
+ protected function extractTransports(array $aResponse): array
+ {
+ $mTransports = $aResponse['transports'] ?? null;
+
+ return is_array($mTransports)
+ ? array_values(array_filter($mTransports, 'is_string'))
+ : [];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reads credProps.rk; null when the browser did not say either way
+ *
+ * @param array $aClientResponse
+ */
+ protected function extractIsDiscoverable(array $aClientResponse): ?bool
+ {
+ $mExtensions = $aClientResponse['clientExtensionResults'] ?? null;
+
+ if (is_object($mExtensions)) {
+ $mExtensions = (array) $mExtensions;
+ }
+
+ if (!is_array($mExtensions)) {
+ return null;
+ }
+
+ $mCredProps = $mExtensions['credProps'] ?? null;
+
+ if (is_object($mCredProps)) {
+ $mCredProps = (array) $mCredProps;
+ }
+
+ if (!is_array($mCredProps) || !array_key_exists('rk', $mCredProps)) {
+ return null;
+ }
+
+ return (bool) $mCredProps['rk'];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Formats a raw 16 byte AAGUID as a UUID; null when the authenticator withheld it
+ */
+ protected function formatAaguid(string $sBinary): ?string
+ {
+ if (strlen($sBinary) !== 16 || trim($sBinary, "\x00") === '') {
+ return null;
+ }
+
+ $sHex = bin2hex($sBinary);
+
+ return sprintf(
+ '%s-%s-%s-%s-%s',
+ substr($sHex, 0, 8),
+ substr($sHex, 8, 4),
+ substr($sHex, 12, 4),
+ substr($sHex, 16, 4),
+ substr($sHex, 20, 12)
+ );
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Normalises a user-supplied label, falling back to a generic one
+ */
+ protected function normaliseLabel(?string $sLabel): string
+ {
+ $sLabel = trim((string) $sLabel);
+
+ if ($sLabel === '') {
+ return 'Passkey';
+ }
+
+ return mb_substr($sLabel, 0, 100);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The name the authenticator shows for the account
+ */
+ protected function getUserName(Resource\User $oUser): string
+ {
+ $sName = (string) ($oUser->email ?: $oUser->username ?: $oUser->id);
+
+ return mb_substr($sName, 0, 64);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * The human-friendly name the authenticator shows for the account
+ */
+ protected function getUserDisplayName(Resource\User $oUser): string
+ {
+ $sName = trim((string) $oUser->name);
+
+ if ($sName === '') {
+ $sName = trim(sprintf('%s %s', $oUser->first_name, $oUser->last_name));
+ }
+
+ return mb_substr($sName !== '' ? $sName : $this->getUserName($oUser), 0, 64);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the lowercase host of a URL
+ */
+ protected function extractHost(string $sUrl): ?string
+ {
+ $sHost = parse_url($sUrl, PHP_URL_HOST);
+
+ return is_string($sHost) && $sHost !== '' ? strtolower($sHost) : null;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Reduces a URL to its origin: scheme://host[:port], with the default port dropped
+ */
+ protected function extractOrigin(string $sUrl): ?string
+ {
+ $sUrl = trim($sUrl);
+
+ if ($sUrl === '') {
+ return null;
+ }
+
+ $aParts = parse_url($sUrl);
+ $sScheme = isset($aParts['scheme']) ? strtolower($aParts['scheme']) : null;
+ $sHost = isset($aParts['host']) ? strtolower($aParts['host']) : null;
+
+ if (empty($sScheme) || empty($sHost)) {
+ return null;
+ }
+
+ $iPort = $aParts['port'] ?? null;
+ $bIsDefault = ($sScheme === 'https' && $iPort === 443) || ($sScheme === 'http' && $iPort === 80);
+
+ return $sScheme . '://' . $sHost . ($iPort && !$bIsDefault ? ':' . $iPort : '');
+ }
+}
From 4e93c420b4dc4442add6dd7f57abb25ffeb677ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:22 +0100
Subject: [PATCH 06/18] feat: Add loginWithPasskey() and a login-method signal
`loginWithPasskey()` mirrors `loginWithCredentials()`: the same brute force delay,
lockout and suspension checks, the same generic failure message, and the same
remember-me handling. It bypasses only the temporary and expired password checks,
because no password took part in the login. An unrecognised credential cannot be
attributed to a user, so it gets the delay and the generic message and nothing else.
`recordLoginMethod()` notes how the session authenticated. It is called immediately
before `setLoginData()` because that fires USER_LOG_IN synchronously and listeners
need to be able to read the signal; the multi-factor module will use it to skip the
challenge for a user-verified passkey login.
`clearLoginData()` unsets the signal, covering the fail-closed path where the MFA
module clears a half-finished login. `logout()` already destroys the session.
No existing signature changes.
Co-Authored-By: Claude Opus 5
---
src/Model/User.php | 5 +-
src/Service/Authentication.php | 190 +++++++++++++++++++++++++++++++++
2 files changed, 194 insertions(+), 1 deletion(-)
diff --git a/src/Model/User.php b/src/Model/User.php
index a01948c3..f21c899b 100644
--- a/src/Model/User.php
+++ b/src/Model/User.php
@@ -21,6 +21,7 @@
use Nails\Auth\Model\User\Group;
use Nails\Auth\Model\User\Password;
use Nails\Auth\Resource;
+use Nails\Auth\Service\Authentication;
use Nails\Common\Exception\EnvironmentException;
use Nails\Common\Exception\FactoryException;
use Nails\Common\Exception\ModelException;
@@ -544,7 +545,9 @@ public function clearLoginData()
$oSession
->unsetUserData('id')
->unsetUserData('email')
- ->unsetUserData('group_id');
+ ->unsetUserData('group_id')
+ // Covers the MFA fail-closed path; logout() destroys the session outright
+ ->unsetUserData(Authentication::SESSION_KEY_LOGIN_METHOD);
// Set the flag
$this->bIsLoggedIn = false;
diff --git a/src/Service/Authentication.php b/src/Service/Authentication.php
index 5e6e24bd..d28bd5bf 100644
--- a/src/Service/Authentication.php
+++ b/src/Service/Authentication.php
@@ -23,6 +23,7 @@
use Nails\Auth\Exception\Login\RequiresPasswordResetExpiredException;
use Nails\Auth\Exception\Login\RequiresPasswordResetTempException;
use Nails\Auth\Exception\Login\RequiresSocialException;
+use Nails\Auth\Exception\Passkey\PasskeyException;
use Nails\Auth\Model\User\Password;
use Nails\Auth\Resource;
use Nails\Common\Exception\Encrypt\DecodeException;
@@ -35,6 +36,7 @@
use Nails\Common\Service\Database;
use Nails\Common\Service\Encrypt;
use Nails\Common\Service\Input;
+use Nails\Common\Service\Session;
use Nails\Common\Traits\ErrorHandling;
use Nails\Environment;
use Nails\Factory;
@@ -79,6 +81,21 @@ class Authentication
*/
const LOCKOUT_DURATION = 300;
+ /**
+ * The session key recording how the current session authenticated
+ *
+ * @var string
+ */
+ const SESSION_KEY_LOGIN_METHOD = 'auth-login-method';
+
+ /**
+ * Login methods reported by the above signal
+ *
+ * @var string
+ */
+ const LOGIN_METHOD_PASSWORD = 'password';
+ const LOGIN_METHOD_PASSKEY = 'passkey';
+
// --------------------------------------------------------------------------
/**
@@ -238,6 +255,12 @@ public function loginWithCredentials(
$oUserModel->setRememberCookie($oUser->id, $oUser->password, $oUser->email);
}
+ /**
+ * Must be recorded before setLoginData(), which fires USER_LOG_IN synchronously;
+ * listeners on that event read this signal.
+ */
+ $this->recordLoginMethod(static::LOGIN_METHOD_PASSWORD, (int) $oUser->id);
+
$oUserModel->setLoginData($oUser->id);
$oUserModel->updateLastLogin($oUser->id);
@@ -246,6 +269,173 @@ public function loginWithCredentials(
// --------------------------------------------------------------------------
+ /**
+ * Log a user in using a passkey
+ *
+ * Mirrors loginWithCredentials(), less the password: the temporary and expired
+ * password checks are bypassed because no password took part in this login (J5).
+ *
+ * @param array $aAssertion The PublicKeyCredential.toJSON() payload
+ * @param string $sChallenge The challenge the assertion answers
+ * @param bool $bRemember Whether to 'remember' the user or not
+ *
+ * @throws FactoryException
+ * @throws InvalidCredentialsException
+ * @throws IsLockedOutException
+ * @throws IsSuspendedException
+ * @throws ModelException
+ * @throws NailsException
+ * @throws NoUserException
+ * @throws ReflectionException
+ */
+ public function loginWithPasskey(
+ array $aAssertion,
+ string $sChallenge,
+ bool $bRemember = false
+ ): Resource\User {
+
+ // Delay execution for a moment (reduces brute force efficiently)
+ if (Environment::not(Environment::ENV_DEV)) {
+ usleep(static::BRUTE_FORCE_DELAY);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /** @var \Nails\Auth\Model\User $oUserModel */
+ $oUserModel = Factory::model('User', Constants::MODULE_SLUG);
+ /** @var Passkey $oPasskeyService */
+ $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG);
+
+ $oPasskey = $oPasskeyService->findByAssertion($aAssertion);
+ $oUser = $oPasskey ? $oPasskey->user() : null;
+
+ /**
+ * An unrecognised credential cannot be attributed to a user, so there is nobody
+ * to rate limit; it gets the delay above and the same message as every other
+ * failure (J6).
+ */
+ if (empty($oUser)) {
+ throw new NoUserException(lang('auth_login_fail_general'));
+
+ } elseif ($this->isLockedOut($oUser)) {
+
+ $oUserModel->incrementFailedLogin($oUser->id, static::LOCKOUT_DURATION);
+ $this->logLoginFailure($oUser, 'brute_force_block_in_affect');
+
+ throw new IsLockedOutException(
+ lang('auth_login_fail_blocked', ceil(static::LOCKOUT_DURATION / 60))
+ );
+
+ } elseif ($this->isSuspended($oUser)) {
+
+ $oUserModel->incrementFailedLogin($oUser->id, static::LOCKOUT_DURATION);
+ $this->logLoginFailure($oUser, 'suspended');
+
+ throw new IsSuspendedException(
+ lang('auth_login_fail_suspended')
+ );
+ }
+
+ try {
+
+ $oPasskeyService->completeAuthentication($aAssertion, $sChallenge, $oUser, true);
+
+ } catch (PasskeyException $e) {
+
+ $oUserModel->incrementFailedLogin($oUser->id, static::LOCKOUT_DURATION);
+ $this->logLoginFailure($oUser, 'passkey_invalid');
+
+ throw new InvalidCredentialsException(lang('auth_login_fail_general'));
+ }
+
+ // Successful login means we can forget about failures
+ $oUserModel->resetFailedLogin($oUser->id);
+
+ /**
+ * The assertion above required user verification, so this login satisfies an
+ * MFA challenge; see the MFA module's requiresAuthentication().
+ */
+ $this->recordLoginMethod(static::LOGIN_METHOD_PASSKEY, (int) $oUser->id, true);
+
+ // Note: a no-op for users without a password, as it always has been (J10)
+ if ($bRemember) {
+ $oUserModel->setRememberCookie($oUser->id, $oUser->password, $oUser->email);
+ }
+
+ $oUserModel->setLoginData($oUser->id);
+ $oUserModel->updateLastLogin($oUser->id);
+
+ return $oUser;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Records how the current session authenticated
+ *
+ * Must be called before setLoginData() so that USER_LOG_IN listeners can read it.
+ *
+ * @throws FactoryException
+ */
+ public function recordLoginMethod(string $sMethod, int $iUserId, bool $bUserVerified = false): void
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+ $oSession->setUserData(static::SESSION_KEY_LOGIN_METHOD, (object) [
+ 'method' => $sMethod,
+ 'user_id' => $iUserId,
+ 'user_verified' => $bUserVerified,
+ 'at' => time(),
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Returns the signal describing how the current session authenticated, if any
+ *
+ * @throws FactoryException
+ */
+ public function getLoginMethod(): ?stdClass
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+
+ $mSignal = $oSession->getUserData(static::SESSION_KEY_LOGIN_METHOD);
+
+ return is_object($mSignal) ? (object) $mSignal : null;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Forgets how the current session authenticated
+ *
+ * @throws FactoryException
+ */
+ public function clearLoginMethod(): void
+ {
+ /** @var Session $oSession */
+ $oSession = Factory::service('Session');
+ $oSession->unsetUserData(static::SESSION_KEY_LOGIN_METHOD);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether the current session authenticated with a user-verified credential
+ *
+ * @throws FactoryException
+ */
+ public function isLoginUserVerified(): bool
+ {
+ $oSignal = $this->getLoginMethod();
+
+ return !empty($oSignal->user_verified);
+ }
+
+ // --------------------------------------------------------------------------
+
/**
* Determines whether a user is currently locked out
*
From cf824784b9f84ba54666571d3e388bfdf199b614 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:34 +0100
Subject: [PATCH 07/18] feat: Add the passkey API endpoints
Seven endpoints covering both ceremonies plus management. Only `challenge` and
`assert` are reachable logged out; everything else manages an existing account.
All of them 404 when passkeys are disabled, so the feature simply does not exist
until it is switched on.
There is no CSRF token on API routes, so the write endpoints lean on two things
instead. Every POST must be `application/json`, which a cross-site form cannot send
without a preflight; and the request must look same-origin, using the browser's own
fetch metadata where it is sent and falling back to Origin/Referer where it is not.
`attest` and `assert` are additionally bound by the session challenge and by the
library's own origin check.
`return_to` is restricted to this site: relative paths are resolved against it, and
an absolute URL is only honoured when its host matches BASE_URL.
Failures map to status codes that do not leak whether a credential exists; an
unrecognised passkey and a bad signature both return the same generic 401.
Co-Authored-By: Claude Opus 5
---
src/Api/Controller/Passkey.php | 601 +++++++++++++++++++++++++++++++++
1 file changed, 601 insertions(+)
create mode 100644 src/Api/Controller/Passkey.php
diff --git a/src/Api/Controller/Passkey.php b/src/Api/Controller/Passkey.php
new file mode 100644
index 00000000..6a5d1436
--- /dev/null
+++ b/src/Api/Controller/Passkey.php
@@ -0,0 +1,601 @@
+
+ */
+ public static function isAuthenticated($sHttpMethod = '', $sMethod = '')
+ {
+ if (in_array(strtolower((string) $sMethod), static::PUBLIC_METHODS, true)) {
+ return true;
+ }
+
+ return isLoggedIn();
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Lists the active user's passkeys
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function getIndex(): ApiResponse
+ {
+ $this->assertEnabled();
+
+ /** @var PasskeyModel $oModel */
+ $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+
+ return $this
+ ->response()
+ ->setData(array_map(
+ fn(Resource\User\Passkey $oPasskey): array => $this->formatPasskey($oPasskey),
+ $oModel->getByUserId((int) activeUser('id'))
+ ));
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Begins registration: returns creation options and remembers the challenge
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function postRegister(): ApiResponse
+ {
+ $this->assertEnabled();
+ $this->assertJsonRequest();
+ $this->assertSameOriginRequest();
+
+ return $this->guard(function (): ApiResponse {
+
+ $oOptions = $this
+ ->passkeyService()
+ ->createRegistrationOptions($this->activeUserResource());
+
+ return $this
+ ->response()
+ ->setData(['options' => $oOptions->options]);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Completes registration and stores the passkey
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function postAttest(): ApiResponse
+ {
+ $this->assertEnabled();
+ $this->assertJsonRequest();
+ $this->assertSameOriginRequest();
+
+ return $this->guard(function (): ApiResponse {
+
+ $aData = $this->getRequestData();
+ $oService = $this->passkeyService();
+ $oUser = $this->activeUserResource();
+
+ $sChallenge = $oService->consumeChallenge(
+ PasskeyService::PURPOSE_REGISTRATION,
+ (int) $oUser->id
+ );
+
+ $oPasskey = $oService->completeRegistration(
+ $oUser,
+ $this->requireCredential($aData),
+ $sChallenge,
+ is_string($aData['label'] ?? null) ? $aData['label'] : null
+ );
+
+ // They have one now, so stop nudging them on this browser
+ $oService->setNudgeDismissed();
+
+ return $this
+ ->response()
+ ->setData(['passkey' => $this->formatPasskey($oPasskey)]);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Mints a discoverable authentication challenge for a logged out visitor
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function postChallenge(): ApiResponse
+ {
+ $this->assertEnabled();
+ $this->assertJsonRequest();
+ $this->assertSameOriginRequest();
+
+ return $this->guard(function (): ApiResponse {
+
+ $oOptions = $this
+ ->passkeyService()
+ ->createAuthenticationOptions(null, PasskeyService::UV_REQUIRED);
+
+ return $this
+ ->response()
+ ->setData(['options' => $oOptions->options]);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Verifies an assertion and logs the user in
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function postAssert(): ApiResponse
+ {
+ $this->assertEnabled();
+ $this->assertJsonRequest();
+ $this->assertSameOriginRequest();
+
+ return $this->guard(function (): ApiResponse {
+
+ $aData = $this->getRequestData();
+ $oService = $this->passkeyService();
+ /** @var Authentication $oAuthService */
+ $oAuthService = Factory::service('Authentication', Constants::MODULE_SLUG);
+
+ $sChallenge = $oService->consumeChallenge(PasskeyService::PURPOSE_AUTHENTICATION, null);
+
+ $oUser = $oAuthService->loginWithPasskey(
+ $this->requireCredential($aData),
+ $sChallenge,
+ (bool) ($aData['remember'] ?? false)
+ );
+
+ $this->welcome($oUser);
+
+ createUserEvent('did_log_in', ['provider' => 'passkey']);
+
+ return $this
+ ->response()
+ ->setData([
+ 'redirect' => $this->sanitiseReturnTo(
+ is_string($aData['return_to'] ?? null) ? $aData['return_to'] : null,
+ $oUser
+ ),
+ ]);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Renames one of the active user's passkeys
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function postRename(): ApiResponse
+ {
+ $this->assertEnabled();
+ $this->assertJsonRequest();
+ $this->assertSameOriginRequest();
+
+ return $this->guard(function (): ApiResponse {
+
+ $aData = $this->getRequestData();
+ $oPasskey = $this->requireOwnedPasskey($aData);
+ $sLabel = trim((string) ($aData['label'] ?? ''));
+
+ if ($sLabel === '') {
+ throw new ValidationException('A label is required.');
+ }
+
+ $this->passkeyService()->rename($oPasskey, $sLabel);
+
+ /** @var PasskeyModel $oModel */
+ $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+ /** @var Resource\User\Passkey|null $oUpdated */
+ $oUpdated = $oModel->getById((int) $oPasskey->id);
+
+ return $this
+ ->response()
+ ->setData(['passkey' => $this->formatPasskey($oUpdated ?? $oPasskey)]);
+ });
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Removes one of the active user's passkeys
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ public function postRevoke(): ApiResponse
+ {
+ $this->assertEnabled();
+ $this->assertJsonRequest();
+ $this->assertSameOriginRequest();
+
+ return $this->guard(function (): ApiResponse {
+
+ $this->passkeyService()->revoke(
+ $this->requireOwnedPasskey($this->getRequestData())
+ );
+
+ return $this->response();
+ });
+ }
+
+ // --------------------------------------------------------------------------
+ // Internals
+ // --------------------------------------------------------------------------
+
+ /**
+ * Runs an endpoint, translating passkey failures into API responses
+ *
+ * @param callable(): ApiResponse $cCallback
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ * @throws ValidationException
+ */
+ protected function guard(callable $cCallback): ApiResponse
+ {
+ /** @var HttpCodes $oHttpCodes */
+ $oHttpCodes = Factory::service('HttpCodes');
+
+ try {
+
+ return $cCallback();
+
+ } catch (InvalidResponseException|ChallengeException|CredentialExistsException|OriginNotAllowedException $e) {
+
+ throw new Api\Exception\ApiException(
+ $e->getMessage(),
+ $oHttpCodes::STATUS_BAD_REQUEST
+ );
+
+ } catch (IsLockedOutException|IsSuspendedException $e) {
+
+ throw new Api\Exception\ApiException(
+ $e->getMessage(),
+ $oHttpCodes::STATUS_FORBIDDEN
+ );
+
+ } catch (NoUserException|InvalidCredentialsException $e) {
+
+ // Deliberately generic: the caller must not learn which credential exists
+ throw new Api\Exception\ApiException(
+ $e->getMessage() ?: lang('auth_login_fail_general'),
+ $oHttpCodes::STATUS_UNAUTHORIZED
+ );
+
+ } catch (PasskeyException $e) {
+
+ throw new Api\Exception\ApiException(
+ lang('auth_login_fail_general'),
+ $oHttpCodes::STATUS_UNAUTHORIZED
+ );
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * 404s when passkeys are switched off, so the endpoints simply do not exist
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ protected function assertEnabled(): void
+ {
+ if (!$this->passkeyService()->isEnabled()) {
+
+ /** @var HttpCodes $oHttpCodes */
+ $oHttpCodes = Factory::service('HttpCodes');
+
+ throw new Api\Exception\ApiException(
+ 'Passkeys are not enabled.',
+ $oHttpCodes::STATUS_NOT_FOUND
+ );
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Requires a JSON body
+ *
+ * A form post cannot set this content type cross-origin without a preflight, so
+ * requiring it keeps these endpoints out of reach of a simple cross-site form.
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ protected function assertJsonRequest(): void
+ {
+ /** @var Input $oInput */
+ $oInput = Factory::service('Input');
+ /** @var HttpCodes $oHttpCodes */
+ $oHttpCodes = Factory::service('HttpCodes');
+
+ $sContentType = strtolower(trim(explode(';', (string) $oInput::header('Content-Type'))[0]));
+
+ if ($sContentType !== 'application/json' || !empty($oInput->post())) {
+ throw new Api\Exception\ApiException(
+ 'This endpoint requires a JSON request body.',
+ $oHttpCodes::STATUS_BAD_REQUEST
+ );
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Requires the request to have come from this site
+ *
+ * There is no CSRF token on API routes, so this leans on the browser's own
+ * fetch metadata, falling back to Origin/Referer where it is not sent.
+ *
+ * @throws Api\Exception\ApiException
+ * @throws FactoryException
+ */
+ protected function assertSameOriginRequest(): void
+ {
+ /** @var Input $oInput */
+ $oInput = Factory::service('Input');
+ /** @var HttpCodes $oHttpCodes */
+ $oHttpCodes = Factory::service('HttpCodes');
+
+ $sFetchSite = strtolower((string) $oInput::header('Sec-Fetch-Site'));
+
+ if ($sFetchSite !== '') {
+ if (in_array($sFetchSite, ['same-origin', 'none'], true)) {
+ return;
+ }
+
+ throw new Api\Exception\ApiException(
+ 'Cross-site requests are not permitted.',
+ $oHttpCodes::STATUS_BAD_REQUEST
+ );
+ }
+
+ $sHost = (string) parse_url((string) Config::get('BASE_URL'), PHP_URL_HOST);
+ $sSource = (string) ($oInput::header('Origin') ?: $oInput::header('Referer'));
+
+ if ($sSource === '' || strtolower((string) parse_url($sSource, PHP_URL_HOST)) !== strtolower($sHost)) {
+ throw new Api\Exception\ApiException(
+ 'Cross-site requests are not permitted.',
+ $oHttpCodes::STATUS_BAD_REQUEST
+ );
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @param array $aData
+ *
+ * @return array
+ * @throws InvalidResponseException
+ */
+ protected function requireCredential(array $aData): array
+ {
+ $mCredential = $aData['credential'] ?? null;
+
+ if (is_object($mCredential)) {
+ $mCredential = (array) $mCredential;
+ }
+
+ if (!is_array($mCredential)) {
+ throw new InvalidResponseException('No credential was supplied.');
+ }
+
+ return $mCredential;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Resolves a passkey ID from the request, refusing anybody else's
+ *
+ * @param array $aData
+ *
+ * @throws FactoryException
+ * @throws ValidationException
+ */
+ protected function requireOwnedPasskey(array $aData): Resource\User\Passkey
+ {
+ /** @var PasskeyModel $oModel */
+ $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+
+ /** @var Resource\User\Passkey|null $oPasskey */
+ $oPasskey = $oModel->getById((int) ($aData['id'] ?? 0));
+
+ if (empty($oPasskey) || (int) $oPasskey->user_id !== (int) activeUser('id')) {
+ throw new ValidationException('Unrecognised passkey.');
+ }
+
+ return $oPasskey;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Restricts a return URL to this site, falling back to the group homepage
+ *
+ * @throws FactoryException
+ */
+ protected function sanitiseReturnTo(?string $sReturnTo, Resource\User $oUser): string
+ {
+ $sFallback = (string) ($oUser->group_homepage ?: siteUrl());
+ $sReturnTo = trim((string) $sReturnTo);
+
+ if ($sReturnTo === '') {
+ return $sFallback;
+ }
+
+ // A protocol-relative URL would leave the site while looking relative
+ if (str_starts_with($sReturnTo, '//')) {
+ return $sFallback;
+ }
+
+ $sHost = parse_url($sReturnTo, PHP_URL_HOST);
+
+ if (empty($sHost)) {
+ return siteUrl(ltrim($sReturnTo, '/'));
+ }
+
+ $sBaseHost = (string) parse_url((string) Config::get('BASE_URL'), PHP_URL_HOST);
+
+ return strtolower((string) $sHost) === strtolower($sBaseHost)
+ ? $sReturnTo
+ : $sFallback;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Adds the same welcome message a password login would have shown
+ *
+ * @throws FactoryException
+ */
+ protected function welcome(Resource\User $oUser): void
+ {
+ /** @var UserFeedback $oUserFeedback */
+ $oUserFeedback = Factory::service('UserFeedback');
+
+ if ($oUser->last_login) {
+ $oUserFeedback->success(lang(
+ 'auth_login_ok_welcome',
+ [$oUser->first_name, toUserDatetime($oUser->last_login)]
+ ));
+ } else {
+ $oUserFeedback->success(lang('auth_login_ok_welcome_notime', [$oUser->first_name]));
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @return array
+ */
+ protected function formatPasskey(Resource\User\Passkey $oPasskey): array
+ {
+ return [
+ 'id' => (int) $oPasskey->id,
+ 'label' => $oPasskey->label,
+ 'created' => (string) $oPasskey->created,
+ 'last_used' => $oPasskey->last_used ? (string) $oPasskey->last_used : null,
+ 'is_backed_up' => $oPasskey->is_backed_up,
+ 'transports' => $oPasskey->getTransports(),
+ 'aaguid' => $oPasskey->aaguid,
+ ];
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ protected function passkeyService(): PasskeyService
+ {
+ /** @var PasskeyService $oService */
+ $oService = Factory::service('Passkey', Constants::MODULE_SLUG);
+
+ return $oService;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ protected function activeUserResource(): Resource\User
+ {
+ /** @var Resource\User $oUser */
+ $oUser = activeUser();
+
+ return $oUser;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ protected function response(): ApiResponse
+ {
+ /** @var ApiResponse $oResponse */
+ $oResponse = Factory::factory('ApiResponse', Api\Constants::MODULE_SLUG);
+
+ return $oResponse;
+ }
+}
From 2618ebc70ca4cf5081cfea97de5de92777eb4521 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:34 +0100
Subject: [PATCH 08/18] feat: Add passkey language strings
Co-Authored-By: Claude Opus 5
---
auth/language/english/auth_lang.php | 40 +++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
diff --git a/auth/language/english/auth_lang.php b/auth/language/english/auth_lang.php
index 1ad7fab2..522ce7f8 100644
--- a/auth/language/english/auth_lang.php
+++ b/auth/language/english/auth_lang.php
@@ -175,3 +175,43 @@
$lang['auth_forgot_reminder'] = 'In case you forgot, your temporary password is %s. You won\'t be shown this message again.';
$lang['auth_forgot_reset_ok'] = 'Please log in using this temporary password:';
$lang['auth_forgot_action_proceed'] = 'Proceed to log in';
+
+// --------------------------------------------------------------------------
+
+// Passkey login
+$lang['auth_login_passkey_button'] = 'Sign in with a passkey';
+$lang['auth_login_passkey_cancelled'] = 'Passkey sign in was cancelled.';
+$lang['auth_login_passkey_unsupported'] = 'This browser does not support passkeys.';
+$lang['auth_login_passkey_fail'] = 'Sorry, we could not sign you in with that passkey. Please try again, or use your password.';
+
+// --------------------------------------------------------------------------
+
+// Passkey management
+$lang['auth_passkeys_title'] = 'Passkeys';
+$lang['auth_passkeys_intro'] = 'A passkey lets you sign in with your fingerprint, face, screen lock, or a security key, instead of a password.';
+$lang['auth_passkeys_add'] = 'Add a passkey';
+$lang['auth_passkeys_label'] = 'Name';
+$lang['auth_passkeys_label_placeholder'] = 'e.g. My laptop';
+$lang['auth_passkeys_label_help'] = 'Give this passkey a name so you can recognise it later.';
+$lang['auth_passkeys_label_required'] = 'Please give this passkey a name.';
+$lang['auth_passkeys_added'] = 'Added';
+$lang['auth_passkeys_last_used'] = 'Last used';
+$lang['auth_passkeys_never_used'] = 'Never';
+$lang['auth_passkeys_actions'] = 'Actions';
+$lang['auth_passkeys_rename'] = 'Rename';
+$lang['auth_passkeys_renamed'] = 'Your passkey was renamed.';
+$lang['auth_passkeys_remove'] = 'Remove';
+$lang['auth_passkeys_remove_confirm'] = 'Are you sure you want to remove this passkey?';
+$lang['auth_passkeys_removed'] = 'Your passkey was removed.';
+$lang['auth_passkeys_not_found'] = 'That passkey could not be found.';
+$lang['auth_passkeys_unsupported'] = 'This browser does not support passkeys, so one cannot be added here.';
+$lang['auth_passkeys_created'] = 'Your passkey was added.';
+
+// --------------------------------------------------------------------------
+
+// Passkey nudge
+$lang['auth_passkeys_nudge_title'] = 'Sign in faster next time';
+$lang['auth_passkeys_nudge_body'] = 'Add a passkey and you can sign in with your fingerprint, face, or screen lock instead of typing your password.';
+$lang['auth_passkeys_nudge_add'] = 'Add a passkey';
+$lang['auth_passkeys_nudge_skip'] = 'Not now';
+$lang['auth_passkeys_nudge_continue'] = 'Continue';
From 4bd52991bf9bc0541a43595d25974c1b8f612e66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:44 +0100
Subject: [PATCH 09/18] feat: Offer passkey sign-in on the login page
Adds two ways in. A "Sign in with a passkey" button sits below the password
controls behind a rule, because a passkey is a different way in rather than a
variant of the password; and the identifier field gets the `webauthn` autocomplete
token, which is what lets the browser offer a saved passkey in the field's own
dropdown. The whole block stays hidden until the JavaScript confirms the browser
supports WebAuthn, so an unsupported browser is never left with a rule and nothing
beneath it.
`isViewOverridden()` is added to the auth base controller and reused by
`loadStyles()`, which was already making the same test inline. An app which has
taken the login view over owns its own assets, so the module does not inject its
JavaScript there; the `passkey` helper lets such an app opt back in with
`loadPasskeyAssets()` and `passkeyLoginButton()`.
`loadStyles()` keeps its signature; `isViewOverridden()` is additive.
Co-Authored-By: Claude Opus 5
---
auth/controllers/Login.php | 66 +++++++++++++++++++-
auth/views/login/form.php | 42 ++++++++++++-
helpers/passkey.php | 119 +++++++++++++++++++++++++++++++++++++
src/Controller/Base.php | 17 +++++-
4 files changed, 239 insertions(+), 5 deletions(-)
create mode 100644 helpers/passkey.php
diff --git a/auth/controllers/Login.php b/auth/controllers/Login.php
index c4995833..156407fa 100644
--- a/auth/controllers/Login.php
+++ b/auth/controllers/Login.php
@@ -21,12 +21,14 @@
use Nails\Auth\Model\User\Password;
use Nails\Auth\Resource;
use Nails\Auth\Service\Authentication;
+use Nails\Auth\Service\Passkey;
use Nails\Auth\Service\SocialSignOn;
use Nails\Auth\Validator\User\Identifier;
use Nails\Auth\Validator\User\Identity;
use Nails\Cdn\Service\Cdn;
use Nails\Common\Exception\FactoryException;
use Nails\Common\Exception\ValidationException;
+use Nails\Common\Service\Asset;
use Nails\Common\Service\Config;
use Nails\Common\Service\FileCache;
use Nails\Common\Service\FormValidation;
@@ -178,13 +180,31 @@ public function index()
// --------------------------------------------------------------------------
- $this->loadStyles(\Nails\Config::get('NAILS_APP_PATH') . 'application/modules/auth/views/login/form.php');
+ /** @var Passkey $oPasskeyService */
+ $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG);
+ $this->data['passkeys_enabled'] = $oPasskeyService->isEnabled();
+
+ // --------------------------------------------------------------------------
+
+ $sAppView = \Nails\Config::get('NAILS_APP_PATH') . 'application/modules/auth/views/login/form.php';
+
+ $this->loadStyles($sAppView);
// Re-boot captcha as loadStyles clears everything
if (appSetting('user_login_captcha_enabled', 'auth')) {
$oCaptchaService->boot();
}
+ /**
+ * An app which has overridden the view owns its own assets; it can opt in
+ * with loadPasskeyAssets() from the passkey helper.
+ */
+ if ($this->data['passkeys_enabled'] && !$this->isViewOverridden($sAppView)) {
+ /** @var Asset $oAsset */
+ $oAsset = Factory::service('Asset');
+ $oAsset->load('passkey.min.js', Constants::MODULE_SLUG, 'JS', false, true);
+ }
+
Factory::service('View')
->load([
'structure/header/blank',
@@ -256,12 +276,56 @@ protected function handleLogin(Resource\User $oUser, bool $bRemember = false, st
// --------------------------------------------------------------------------
+ if ($this->shouldNudgeForPasskey($oUser, $sProvider)) {
+
+ /** @var Passkey $oPasskeyService */
+ $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG);
+ $oPasskeyService->markNudged($sRedirectUrl);
+
+ redirect('auth/passkeys/nudge');
+ }
+
+ // --------------------------------------------------------------------------
+
redirect($sRedirectUrl);
}
}
// --------------------------------------------------------------------------
+ /**
+ * Whether to offer this user a passkey before sending them on their way
+ *
+ * Only a plain native login is nudged: a social login has no password to replace,
+ * and an MFA-challenged login never returns through here (J8).
+ *
+ * @throws FactoryException
+ */
+ protected function shouldNudgeForPasskey(Resource\User $oUser, string $sProvider): bool
+ {
+ if ($sProvider !== 'native') {
+ return false;
+ }
+
+ /** @var Passkey $oPasskeyService */
+ $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG);
+
+ if (!$oPasskeyService->isEnabled() || $oPasskeyService->isNudgeDismissed()) {
+ return false;
+ }
+
+ if ($oPasskeyService->hasBeenNudged()) {
+ return false;
+ }
+
+ /** @var \Nails\Auth\Model\User\Passkey $oPasskeyModel */
+ $oPasskeyModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+
+ return $oPasskeyModel->countForUser((int) $oUser->id) === 0;
+ }
+
+ // --------------------------------------------------------------------------
+
/**
* Handle MFA redirect
*
diff --git a/auth/views/login/form.php b/auth/views/login/form.php
index b612ce8e..cc7f35a5 100644
--- a/auth/views/login/form.php
+++ b/auth/views/login/form.php
@@ -25,7 +25,12 @@
load('auth/_components/alerts');
if ($social_signon_enabled) {
@@ -86,8 +91,17 @@
break;
}
- $sFieldKey = 'identifier';
- $sFieldAttr = 'id="input-' . $sFieldKey . '" placeholder="' . $sFieldPlaceholder . '" class="form__control"';
+ $sFieldKey = 'identifier';
+ $sFieldAttr = 'id="input-' . $sFieldKey . '" placeholder="' . $sFieldPlaceholder . '" class="form__control"';
+
+ /**
+ * `webauthn` on the autocomplete token is what lets the browser offer a
+ * saved passkey in the field's own dropdown (conditional mediation).
+ */
+ if (!empty($passkeys_enabled)) {
+ $sFieldAttr .= ' autocomplete="username webauthn" data-passkey-conditional';
+ }
+
$sFieldValue = set_value($sFieldKey, $oInput->get('identity'), false);
?>
@@ -138,6 +152,28 @@
?>
+
+
+
+
+
+
+
=form_close()?>
isEnabled();
+ }
+}
+
+if (!function_exists('loadPasskeyAssets')) {
+
+ /**
+ * Loads the passkey JavaScript, if passkeys are enabled
+ *
+ * Safe to call more than once; the asset service de-duplicates.
+ */
+ function loadPasskeyAssets(): void
+ {
+ if (!passkeysEnabled()) {
+ return;
+ }
+
+ /** @var Asset $oAsset */
+ $oAsset = Factory::service('Asset');
+ $oAsset->load('passkey.min.js', Constants::MODULE_SLUG, 'JS', false, true);
+ }
+}
+
+if (!function_exists('passkeyLoginButton')) {
+
+ /**
+ * Returns the "Sign in with a passkey" block: a rule, the button, and its error
+ * placeholder
+ *
+ * Place it below the password controls; a passkey is a different way in rather
+ * than a variant of the password. The whole block is hidden until the JavaScript
+ * establishes that the browser supports WebAuthn, so a browser which cannot use
+ * it is never left with a rule and nothing beneath it.
+ *
+ * @param string|null $sReturnTo Where to send the user once they are signed in
+ * @param string|null $sLabel Overrides the button's text
+ * @param string $sAttr Additional attributes for the button
+ * @param bool $bSeparator Whether to draw the rule above the button
+ */
+ function passkeyLoginButton(
+ ?string $sReturnTo = null,
+ ?string $sLabel = null,
+ string $sAttr = 'class="btn btn--block btn--secondary"',
+ bool $bSeparator = true
+ ): string {
+
+ if (!passkeysEnabled()) {
+ return '';
+ }
+
+ return sprintf(
+ '
' .
+ '%s' .
+ '' .
+ '' .
+ '
',
+ $bSeparator ? '' : '',
+ htmlspecialchars((string) $sReturnTo, ENT_QUOTES),
+ $sAttr,
+ htmlspecialchars($sLabel ?: lang('auth_login_passkey_button'), ENT_QUOTES)
+ );
+ }
+}
+
+if (!function_exists('passkeyRegisterButton')) {
+
+ /**
+ * Returns an "Add a passkey" button, plus its error placeholder
+ *
+ * @param string|null $sLabel The button's text
+ * @param string $sAttr Additional attributes for the button
+ */
+ function passkeyRegisterButton(
+ ?string $sLabel = null,
+ string $sAttr = 'class="btn btn--primary"'
+ ): string {
+
+ if (!passkeysEnabled()) {
+ return '';
+ }
+
+ return sprintf(
+ '' .
+ '',
+ $sAttr,
+ htmlspecialchars($sLabel ?: lang('auth_passkeys_add'), ENT_QUOTES)
+ );
+ }
+}
diff --git a/src/Controller/Base.php b/src/Controller/Base.php
index b73d809b..881eb0ec 100644
--- a/src/Controller/Base.php
+++ b/src/Controller/Base.php
@@ -43,11 +43,26 @@ public function __construct()
protected function loadStyles($sView)
{
// Test if a view has been provided by the app
- if (!is_file($sView)) {
+ if (!$this->isViewOverridden($sView)) {
$oAsset = Factory::service('Asset');
$oAsset->clear();
$oAsset->load('nails.min.css', \Nails\Common\Constants::MODULE_SLUG);
$oAsset->load('styles.min.css', Constants::MODULE_SLUG);
}
}
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Whether the app has supplied its own copy of a view
+ *
+ * An app which has taken a view over owns its markup and its assets, so the
+ * module must not assume its own are wanted.
+ *
+ * @param string $sView Absolute path to the view the app would provide
+ */
+ protected function isViewOverridden(string $sView): bool
+ {
+ return is_file($sView);
+ }
}
From 5fb91dcf287a27e1a81b9feacddf4e4f8e1969b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Thu, 10 Sep 2026 22:07:53 +0100
Subject: [PATCH 10/18] feat: Add passkey self-service management and an
adoption nudge
`auth/passkeys` lists a user's passkeys and lets them add, rename and remove one.
Rename and remove are plain form posts so the page keeps working without
JavaScript; only adding a passkey needs the browser API. Nothing is rendered when
there are none, since the panel above already invites the user to add one.
Below 768px the table stacks into a block per passkey, each cell carrying the
heading it lost, because four columns and a text input cannot fit a phone and a
table which scrolls sideways hides the controls people came for.
After a password login a user with no passkeys is offered one, once per browser.
Declining sets a cookie rather than user meta, because the capability being nudged
towards belongs to the browser and not to the account. A browser with no platform
authenticator answers on the user's behalf, so nobody is asked for something their
device cannot provide.
Co-Authored-By: Claude Opus 5
---
auth/controllers/Passkeys.php | 220 ++++++++++++++++++++++++++++++++++
auth/views/passkeys/index.php | 122 +++++++++++++++++++
auth/views/passkeys/nudge.php | 77 ++++++++++++
3 files changed, 419 insertions(+)
create mode 100644 auth/controllers/Passkeys.php
create mode 100644 auth/views/passkeys/index.php
create mode 100644 auth/views/passkeys/nudge.php
diff --git a/auth/controllers/Passkeys.php b/auth/controllers/Passkeys.php
new file mode 100644
index 00000000..b8a4da3c
--- /dev/null
+++ b/auth/controllers/Passkeys.php
@@ -0,0 +1,220 @@
+passkeyService()->isEnabled()) {
+ show404();
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Lists, renames and removes the active user's passkeys
+ *
+ * The rename and remove actions are plain form posts so that the page keeps
+ * working without JavaScript; only adding a passkey needs the browser API.
+ *
+ * @throws FactoryException
+ */
+ public function index(): void
+ {
+ /** @var Input $oInput */
+ $oInput = Factory::service('Input');
+ /** @var UserFeedback $oUserFeedback */
+ $oUserFeedback = Factory::service('UserFeedback');
+ /** @var View $oView */
+ $oView = Factory::service('View');
+ /** @var PasskeyModel $oModel */
+ $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+
+ $sAction = (string) $oInput::post('action');
+
+ if ($sAction === 'rename' || $sAction === 'remove') {
+
+ try {
+
+ $oPasskey = $this->requireOwnedPasskey((int) $oInput::post('id'));
+
+ if ($sAction === 'rename') {
+
+ $sLabel = trim((string) $oInput::post('label'));
+
+ if ($sLabel === '') {
+ throw new PasskeyException(lang('auth_passkeys_label_required'));
+ }
+
+ $this->passkeyService()->rename($oPasskey, $sLabel);
+ $oUserFeedback->success(lang('auth_passkeys_renamed'));
+
+ } else {
+ $this->passkeyService()->revoke($oPasskey);
+ $oUserFeedback->success(lang('auth_passkeys_removed'));
+ }
+
+ } catch (PasskeyException $e) {
+ $oUserFeedback->error($e->getMessage());
+ }
+
+ redirect('auth/passkeys');
+ }
+
+ // --------------------------------------------------------------------------
+
+ $this->data['aPasskeys'] = $oModel->getByUserId((int) activeUser('id'));
+
+ $this->oMetaData->setTitles([lang('auth_passkeys_title')]);
+
+ $this->loadPageAssets('index');
+
+ $oView
+ ->load([
+ 'structure/header/blank',
+ 'auth/passkeys/index',
+ 'structure/footer/blank',
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Offers a passkey to a user who has just signed in with a password
+ *
+ * @throws FactoryException
+ */
+ public function nudge(): void
+ {
+ /** @var Input $oInput */
+ $oInput = Factory::service('Input');
+ /** @var View $oView */
+ $oView = Factory::service('View');
+
+ $oService = $this->passkeyService();
+ $sReturnTo = $oService->consumeNudgeReturn() ?: (string) activeUser()->group_homepage;
+
+ $sAction = (string) $oInput::post('action');
+
+ if ($sAction === 'skip' || $sAction === 'dismiss') {
+
+ /**
+ * "skip" is this browser saying it has no platform authenticator, "dismiss"
+ * is the user saying no; either way, stop asking on this browser.
+ */
+ $oService->setNudgeDismissed();
+
+ redirect($sReturnTo);
+ }
+
+ // Reading it consumed it, so put it back for the form to post against
+ $oService->markNudged($sReturnTo);
+
+ $this->data['sReturnTo'] = $sReturnTo;
+
+ $this->oMetaData->setTitles([lang('auth_passkeys_nudge_title')]);
+
+ $this->loadPageAssets('nudge');
+
+ $oView
+ ->load([
+ 'structure/header/blank',
+ 'auth/passkeys/nudge',
+ 'structure/footer/blank',
+ ]);
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Loads the styles and JavaScript this page needs, unless the app overrode it
+ *
+ * @throws FactoryException
+ */
+ protected function loadPageAssets(string $sView): void
+ {
+ $sAppView = \Nails\Config::get('NAILS_APP_PATH')
+ . 'application/modules/auth/views/passkeys/' . $sView . '.php';
+
+ $this->loadStyles($sAppView);
+
+ if (!$this->isViewOverridden($sAppView)) {
+ /** @var Asset $oAsset */
+ $oAsset = Factory::service('Asset');
+ $oAsset->load('passkey.min.js', Constants::MODULE_SLUG, 'JS', false, true);
+ }
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * Resolves a passkey ID, refusing anybody else's
+ *
+ * @throws FactoryException
+ * @throws PasskeyException
+ */
+ protected function requireOwnedPasskey(int $iId): Resource\User\Passkey
+ {
+ /** @var PasskeyModel $oModel */
+ $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG);
+
+ /** @var Resource\User\Passkey|null $oPasskey */
+ $oPasskey = $oModel->getById($iId);
+
+ if (empty($oPasskey) || (int) $oPasskey->user_id !== (int) activeUser('id')) {
+ throw new PasskeyException(lang('auth_passkeys_not_found'));
+ }
+
+ return $oPasskey;
+ }
+
+ // --------------------------------------------------------------------------
+
+ /**
+ * @throws FactoryException
+ */
+ protected function passkeyService(): PasskeyService
+ {
+ /** @var PasskeyService $oService */
+ $oService = Factory::service('Passkey', Constants::MODULE_SLUG);
+
+ return $oService;
+ }
+}
diff --git a/auth/views/passkeys/index.php b/auth/views/passkeys/index.php
new file mode 100644
index 00000000..8854e8a0
--- /dev/null
+++ b/auth/views/passkeys/index.php
@@ -0,0 +1,122 @@
+
+
+
Date: Thu, 10 Sep 2026 22:08:02 +0100
Subject: [PATCH 11/18] feat: Add admin passkey visibility and the enable
setting
A Passkeys tab when editing a user lists their credentials and offers a checkbox to
revoke. Revocation happens in getPostData() rather than through the returned array,
because a passkey is a row of its own rather than a column on the user, and each ID
is checked against the user being edited so a stray value cannot revoke somebody
else's. Removals are logged with the admin who made them.
Settings > Authentication > Login gains the switch which turns passkeys on, plus
the effective Relying Party ID and permitted origins shown read only. Those are
derived from BASE_URL and can only be overridden in config, because changing the
Relying Party ID invalidates every passkey already registered.
Co-Authored-By: Claude Opus 5
---
admin/controllers/Settings.php | 2 +
.../language/english/admin_accounts_lang.php | 14 ++
admin/views/Accounts/edit/inc-passkeys.php | 107 ++++++++++++++
admin/views/Settings/index.php | 38 +++++
src/Auth/Admin/User/Tab/Passkeys.php | 139 ++++++++++++++++++
5 files changed, 300 insertions(+)
create mode 100644 admin/views/Accounts/edit/inc-passkeys.php
create mode 100644 src/Auth/Admin/User/Tab/Passkeys.php
diff --git a/admin/controllers/Settings.php b/admin/controllers/Settings.php
index 60ab170f..4937b6be 100644
--- a/admin/controllers/Settings.php
+++ b/admin/controllers/Settings.php
@@ -16,6 +16,7 @@
use Nails\Admin\Helper;
use Nails\Auth\Constants;
use Nails\Auth\Controller\BaseAdmin;
+use Nails\Auth\Service\Passkey;
use Nails\Auth\Service\SocialSignOn;
use Nails\Common\Service\AppSetting;
use Nails\Common\Service\Asset;
@@ -113,6 +114,7 @@ public function index(): void
if (userHasPermission('admin:auth:settings:update:login')) {
$aSettings['user_login_captcha_enabled'] = (bool) $oInput->post('user_login_captcha_enabled');
+ $aSettings[Passkey::SETTING_ENABLED] = (bool) $oInput->post(Passkey::SETTING_ENABLED);
}
// --------------------------------------------------------------------------
diff --git a/admin/language/english/admin_accounts_lang.php b/admin/language/english/admin_accounts_lang.php
index e9677632..271f14f5 100644
--- a/admin/language/english/admin_accounts_lang.php
+++ b/admin/language/english/admin_accounts_lang.php
@@ -131,3 +131,17 @@
$lang['accounts_delete_error_selfie'] = 'You can\'t delete yourself.';
$lang['accounts_delete_success'] = 'User %s was deleted successfully.';
$lang['accounts_delete_error'] = 'There was a problem deleting %s.';
+
+// --------------------------------------------------------------------------
+
+// Passkeys
+$lang['accounts_edit_passkeys_none'] = 'This user has not registered any passkeys.';
+$lang['accounts_edit_passkeys_disabled'] = 'Passkeys are not currently enabled for this site; existing passkeys are listed below but cannot be used to sign in.';
+$lang['accounts_edit_passkeys_warning'] = 'Revoking a passkey cannot be undone; the user will have to register the device again.';
+$lang['accounts_edit_passkeys_label'] = 'Name';
+$lang['accounts_edit_passkeys_added'] = 'Added';
+$lang['accounts_edit_passkeys_last_used'] = 'Last used';
+$lang['accounts_edit_passkeys_never_used'] = 'Never';
+$lang['accounts_edit_passkeys_details'] = 'Details';
+$lang['accounts_edit_passkeys_revoke'] = 'Revoke';
+$lang['accounts_edit_passkeys_synced'] = 'Synced';
diff --git a/admin/views/Accounts/edit/inc-passkeys.php b/admin/views/Accounts/edit/inc-passkeys.php
new file mode 100644
index 00000000..579333cf
--- /dev/null
+++ b/admin/views/Accounts/edit/inc-passkeys.php
@@ -0,0 +1,107 @@
+
+
+ created);
+ echo Helper::loadDateTimeCell($oPasskey->last_used ? (string) $oPasskey->last_used : null);
+ echo Helper::loadBoolCell($oPasskey->is_backed_up);
+
+ /**
+ * The AAGUID names the model of authenticator; it is only of use
+ * when supporting a user, so it sits under the transports rather
+ * than taking a column of its own.
+ */
+ echo Helper::loadCellAuto(
+ $aTransports
+ ? htmlspecialchars(implode(', ', $aTransports))
+ : null,
+ 'field field--transports',
+ $oPasskey->aaguid
+ ? ' ' . htmlspecialchars($oPasskey->aaguid) . ''
+ : ''
+ );
+
+ ?>
+
+
+
+
+
+
+
+
diff --git a/admin/views/Settings/index.php b/admin/views/Settings/index.php
index 9fa88645..43a7fd12 100644
--- a/admin/views/Settings/index.php
+++ b/admin/views/Settings/index.php
@@ -38,12 +38,50 @@
!userHasPermission('admin:auth:settings:update:login') ? null : [
'label' => 'Login',
'content' => function () {
+
+ /** @var \Nails\Auth\Service\Passkey $oPasskeyService */
+ $oPasskeyService = \Nails\Factory::service('Passkey', \Nails\Auth\Constants::MODULE_SLUG);
+
echo form_field_boolean([
'key' => 'user_login_captcha_enabled',
'label' => 'Captcha',
'default' => (bool) appSetting('user_login_captcha_enabled', 'auth'),
'info' => anchor('admin/captcha/settings', 'Manage captcha settings here'),
]);
+
+ echo form_field_boolean([
+ 'key' => \Nails\Auth\Service\Passkey::SETTING_ENABLED,
+ 'label' => 'Passkeys',
+ 'default' => (bool) appSetting(\Nails\Auth\Service\Passkey::SETTING_ENABLED, 'auth'),
+ 'info' => 'Allows users to sign in with a passkey, and to register one against their account.',
+ ]);
+
+ /**
+ * Read only: both are derived from BASE_URL and can only be overridden in
+ * the app's config, because changing the Relying Party ID invalidates every
+ * passkey already registered.
+ */
+
+ ?>
+
+
+ Relying Party ID:
+ =htmlspecialchars($oPasskeyService->getRpId())?>
+
+
+ Derived from BASE_URL. Override with
+ =\Nails\Auth\Service\Passkey::CONFIG_RP_ID?> and
+ =\Nails\Auth\Service\Passkey::CONFIG_ALLOWED_ORIGINS?>.
+ Changing the Relying Party ID invalidates existing passkeys.
+
+