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 @@ ?>
+ + + 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( + '', + $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 @@ + +
+

+

+ +

+ load('auth/_components/alerts'); + + ?> +
+
+

+
+
+
+ + + +
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + created))?> + last_used + ? 'title="' . toUserDatetime((string) $oPasskey->last_used) . '"' + : ''?>> + last_used + ? niceTime(strtotime((string) $oPasskey->last_used)) + : lang('auth_passkeys_never_used')?> + + + + + + +
+
+ +
diff --git a/auth/views/passkeys/nudge.php b/auth/views/passkeys/nudge.php new file mode 100644 index 00000000..c4622396 --- /dev/null +++ b/auth/views/passkeys/nudge.php @@ -0,0 +1,77 @@ + +
+
+
+

+ +

+
+
+ load('auth/_components/alerts'); + + ?> +

+ +

+
+ + + + + + + + + +
+
+
+
+ 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 @@ + +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + getTransports(); + + ?> + + + 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) . '' + : '' + ); + + ?> + + + + +
+ + + + + + + + + + + +
+ +
+ label)?> + + +
+
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: + getRpId())?> +

+

+ Permitted origins: + getAllowedOrigins()))?> +

+

+ + Derived from BASE_URL. Override with + and + . + Changing the Relying Party ID invalidates existing passkeys. + +

+
+ load( + ['Accounts/edit/inc-passkeys'], + [ + 'oUser' => $oUser, + 'aPasskeys' => $oModel->getByUserId((int) $oUser->id), + 'bEnabled' => $oService->isEnabled(), + ], + true + ); + } + + // -------------------------------------------------------------------------- + + /** + * Returns additional markup, outside of the main
element + */ + public function getAdditionalMarkup(User $oUser): string + { + return ''; + } + + // -------------------------------------------------------------------------- + + /** + * Returns an array of validation rules compatible with Validator objects + * + * @return array + */ + public function getValidationRules(User $oUser): array + { + return []; + } + + // -------------------------------------------------------------------------- + + /** + * Revokes the checked passkeys + * + * Revocation happens here rather than through the returned array because a passkey + * is a row of its own, not a column on the user. + * + * @param array $aPost The POST array + * + * @return array + * @throws FactoryException + * @throws ModelException + */ + public function getPostData(User $oUser, array $aPost): array + { + /** @var PasskeyService $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + $aRevoke = array_filter((array) ($aPost['passkey_revoke'] ?? [])); + + foreach ($aRevoke as $mId) { + + /** @var Resource\User\Passkey|null $oPasskey */ + $oPasskey = $oModel->getById((int) $mId); + + // Only ever this user's own; a stray ID must not revoke somebody else's + if (empty($oPasskey) || (int) $oPasskey->user_id !== (int) $oUser->id) { + continue; + } + + $oService->revoke($oPasskey, ['by_admin' => (int) activeUser('id')]); + } + + return []; + } +} From 4269c83fc83928df7e0ece3d2e2c630d60e05c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Thu, 10 Sep 2026 22:08:12 +0100 Subject: [PATCH 12/18] feat: Add the passkey JavaScript and styles Exposes window.NAILS.PASSKEY so other components can run a ceremony without repeating the encoding work, and wires up the declarative controls the views render. The MFA driver will use the same entry point, which is why the data attribute handlers are here rather than in a view. Three things are less obvious than they look: Controls are bound on a readyState check rather than DOMContentLoaded alone. The asset is deferred and a page may inject it later still, in which case waiting for the event would leave every control hidden. The browser reports "the user declined" and "the browser refused to ask" as the same NotAllowedError, so the two are told apart by whether the page had focus and how long the ceremony ran; nobody declines a prompt in a quarter of a second. And a hooked navigator.credentials can swallow the call entirely, leaving a promise which never settles, so ceremonies carry a watchdog which says so in the console after ten seconds and gives up at the authenticator's own timeout. Without these an environment problem is indistinguishable from nothing happening at all. Co-Authored-By: Claude Opus 5 --- assets/js/passkey.js | 971 ++++++++++++++++++++++++++++++++++++++++ assets/sass/styles.scss | 188 ++++++++ webpack.config.js | 1 + 3 files changed, 1160 insertions(+) create mode 100644 assets/js/passkey.js diff --git a/assets/js/passkey.js b/assets/js/passkey.js new file mode 100644 index 00000000..2bd9b249 --- /dev/null +++ b/assets/js/passkey.js @@ -0,0 +1,971 @@ +'use strict'; + +/** + * WebAuthn passkey support. + * + * Exposes window.NAILS.PASSKEY so that other components - notably the MFA driver, + * which ships no JavaScript of its own - can run a ceremony without repeating any + * of the encoding work. + */ +(function() { + + var API_BASE = 'api/auth/passkey/'; + + var conditionalAbortController = null; + + /** + * Below this, a rejection cannot be somebody deciding not to continue + */ + var MIN_HUMAN_RESPONSE_MS = 250; + + /** + * How long to wait before saying so in the console when no prompt has appeared + */ + var CEREMONY_WARN_MS = 10000; + + /** + * The backstop. A hooked `navigator.credentials` can leave the promise pending + * for ever - no prompt, no rejection, nothing to report - so the ceremony is + * abandoned once the authenticator's own timeout has certainly passed. + */ + var CEREMONY_TIMEOUT_MS = 60000; + + // -------------------------------------------------------------------------- + + /** + * Whether this browser can do WebAuthn at all + * + * @return {boolean} + */ + function isSupported() { + return typeof window.PublicKeyCredential === 'function' && + !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get); + } + + // -------------------------------------------------------------------------- + + /** + * Whether the browser can offer a passkey from a form field's own dropdown + * + * @return {Promise} + */ + function isConditionalMediationAvailable() { + if (!isSupported() || typeof window.PublicKeyCredential.isConditionalMediationAvailable !== 'function') { + return Promise.resolve(false); + } + return window.PublicKeyCredential.isConditionalMediationAvailable().catch(function() { + return false; + }); + } + + // -------------------------------------------------------------------------- + + /** + * Whether this device has a built in authenticator to enrol + * + * @return {Promise} + */ + function isPlatformAuthenticatorAvailable() { + if (!isSupported() || + typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') { + return Promise.resolve(false); + } + return window.PublicKeyCredential + .isUserVerifyingPlatformAuthenticatorAvailable() + .catch(function() { + return false; + }); + } + + // -------------------------------------------------------------------------- + + /** + * Decodes a base64url string into an ArrayBuffer + * + * @param {string} value The base64url encoded value + * + * @return {ArrayBuffer} + */ + function decode(value) { + var padded = value.replace(/-/g, '+').replace(/_/g, '/'); + while (padded.length % 4) { + padded += '='; + } + var binary = window.atob(padded); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + + // -------------------------------------------------------------------------- + + /** + * Encodes an ArrayBuffer as a base64url string + * + * @param {ArrayBuffer} buffer The buffer to encode + * + * @return {string} + */ + function encode(buffer) { + var bytes = new Uint8Array(buffer); + var binary = ''; + for (var i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + // -------------------------------------------------------------------------- + + /** + * Serialises a credential the way PublicKeyCredential.toJSON() would + * + * Used in place of toJSON() where the browser does not implement it yet, so that + * the server only ever sees one shape. + * + * @param {PublicKeyCredential} credential The credential to serialise + * + * @return {Object} + */ + function toJSON(credential) { + if (typeof credential.toJSON === 'function') { + return credential.toJSON(); + } + + var response = credential.response; + var out = { + 'id': credential.id, + 'rawId': encode(credential.rawId), + 'type': credential.type, + 'authenticatorAttachment': credential.authenticatorAttachment || null, + 'clientExtensionResults': credential.getClientExtensionResults + ? credential.getClientExtensionResults() + : {}, + 'response': { + 'clientDataJSON': encode(response.clientDataJSON) + } + }; + + if (response.attestationObject) { + out.response.attestationObject = encode(response.attestationObject); + out.response.transports = typeof response.getTransports === 'function' + ? response.getTransports() + : []; + } else { + out.response.authenticatorData = encode(response.authenticatorData); + out.response.signature = encode(response.signature); + out.response.userHandle = response.userHandle ? encode(response.userHandle) : null; + } + + return out; + } + + // -------------------------------------------------------------------------- + + /** + * Turns the server's creation options back into the binary the browser wants + * + * @param {Object} options The options as sent by the server + * + * @return {Object} + */ + function decodeCreationOptions(options) { + var decoded = Object.assign({}, options); + + decoded.challenge = decode(options.challenge); + decoded.user = Object.assign({}, options.user, {'id': decode(options.user.id)}); + + if (Array.isArray(options.excludeCredentials)) { + decoded.excludeCredentials = options.excludeCredentials.map(function(item) { + return Object.assign({}, item, {'id': decode(item.id)}); + }); + } + + return decoded; + } + + // -------------------------------------------------------------------------- + + /** + * Turns the server's request options back into the binary the browser wants + * + * @param {Object} options The options as sent by the server + * + * @return {Object} + */ + function decodeRequestOptions(options) { + var decoded = Object.assign({}, options); + + decoded.challenge = decode(options.challenge); + + if (Array.isArray(options.allowCredentials)) { + decoded.allowCredentials = options.allowCredentials.map(function(item) { + return Object.assign({}, item, {'id': decode(item.id)}); + }); + } + + return decoded; + } + + // -------------------------------------------------------------------------- + + /** + * Fails a ceremony which never settles + * + * An extension which intercepts `navigator.credentials` can swallow the call + * entirely, leaving a promise which neither resolves nor rejects. Without this + * the user is left looking at a disabled button and the console stays silent. + * + * @param {Promise} promise The ceremony + * @param {number} timeout How long the options gave the authenticator + * @param {AbortController} controls Lets the pending call be abandoned + * + * @return {Promise} + */ + function withWatchdog(promise, timeout, controls) { + + var settled = false; + var mark = function() { + settled = true; + }; + + promise.then(mark, mark); + + var warn = window.setTimeout(function() { + if (!settled) { + console.warn( + '[passkey] no prompt after ' + (CEREMONY_WARN_MS / 1000) + 's. The browser has ' + + 'not opened a passkey prompt and has not reported an error; a password manager ' + + 'or other extension may be intercepting navigator.credentials.' + ); + } + }, CEREMONY_WARN_MS); + + var expire = new Promise(function(resolve, reject) { + window.setTimeout(function() { + if (settled) { + return; + } + if (controls) { + try { + controls.abort(); + } catch (e) { + // Nothing more can be done; the rejection below is what matters + } + } + var error = new Error('The passkey prompt never opened.'); + error.passkeyRefused = true; + error.passkeyTimedOut = true; + reject(error); + }, Math.max(timeout || 0, CEREMONY_TIMEOUT_MS)); + }); + + return Promise.race([promise, expire]).finally(function() { + window.clearTimeout(warn); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Records why a ceremony failed + * + * The browser reports "the user declined" and "the browser refused to ask" with + * the same NotAllowedError, so the two are told apart here: a page which is not + * focused never got to ask, and nobody declines a prompt in a quarter of a + * second. Without this an environment problem looks exactly like a cancellation, + * which is to say it looks like nothing happening at all. + * + * @param {Error} error The error the browser raised + * @param {number} startedAt When the ceremony was started + * + * @return {Error} + */ + function annotateCeremonyError(error, startedAt) { + + var elapsed = Date.now() - startedAt; + var focused = document.hasFocus(); + + if (error && error.name === 'NotAllowedError' && (!focused || elapsed < MIN_HUMAN_RESPONSE_MS)) { + error.passkeyRefused = true; + error.passkeyUnfocused = !focused; + } + + console.warn('[passkey] ceremony failed', { + 'name': error && error.name, + 'message': error && error.message, + 'elapsedMs': elapsed, + 'documentFocused': focused + }); + + return error; + } + + // -------------------------------------------------------------------------- + + /** + * Runs a registration ceremony + * + * @param {Object} options The decoded creation options + * + * @return {Promise} + */ + function create(options) { + + var startedAt = Date.now(); + var controls = new AbortController(); + + var ceremony = navigator.credentials + .create({'publicKey': decodeCreationOptions(options), 'signal': controls.signal}) + .then(function(credential) { + if (!credential) { + throw new Error('cancelled'); + } + return toJSON(credential); + }); + + return withWatchdog(ceremony, options.timeout, controls) + .catch(function(error) { + throw annotateCeremonyError(error, startedAt); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Runs an authentication ceremony + * + * @param {Object} options The decoded request options + * @param {Object} settings Optional mediation and abort signal + * + * @return {Promise} + */ + function get(options, settings) { + settings = settings || {}; + + var request = {'publicKey': decodeRequestOptions(options)}; + + if (settings.mediation) { + request.mediation = settings.mediation; + } + + /** + * The conditional path supplies its own signal so the button can abandon it; + * the modal path gets one of its own so the watchdog has something to pull. + */ + var controls = settings.signal ? null : new AbortController(); + + request.signal = settings.signal || controls.signal; + + var startedAt = Date.now(); + + var ceremony = navigator.credentials + .get(request) + .then(function(credential) { + if (!credential) { + throw new Error('cancelled'); + } + return toJSON(credential); + }); + + /** + * A conditional request legitimately waits for as long as the user ignores the + * field, so only the modal one is watched. + */ + return (settings.mediation === 'conditional' + ? ceremony + : withWatchdog(ceremony, options.timeout, controls) + ).catch(function(error) { + throw annotateCeremonyError(error, startedAt); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Posts JSON to one of the passkey endpoints + * + * @param {string} endpoint The endpoint name + * @param {Object} body The request body + * + * @return {Promise} + */ + function post(endpoint, body) { + return request('POST', endpoint, body); + } + + // -------------------------------------------------------------------------- + + /** + * Talks to one of the passkey endpoints + * + * @param {string} method The HTTP method + * @param {string} endpoint The endpoint name + * @param {Object} body The request body + * + * @return {Promise} + */ + function request(method, endpoint, body) { + return window + .fetch(siteUrl(API_BASE + endpoint), { + 'method': method, + 'credentials': 'same-origin', + 'headers': { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + 'body': method === 'GET' ? undefined : JSON.stringify(body || {}) + }) + .then(function(response) { + + /** + * An older MFA module may still redirect inside setLoginData(); a + * redirected reply is treated as navigation rather than as an error + * the user has to read. + */ + if (response.redirected) { + window.location.assign(response.url); + return new Promise(function() {}); + } + + return response.text().then(function(text) { + + var payload = null; + + try { + payload = JSON.parse(text); + } catch (e) { + payload = null; + } + + if (response.ok && payload) { + return payload.data || {}; + } + + /** + * A failure here is usually a server error rendered as an HTML page, + * which tells the user nothing. Report what can be said usefully and + * put the body in the console for whoever is debugging it. + */ + var message = payload && payload.error + ? payload.error + : 'Sorry, something went wrong (HTTP ' + response.status + '). Please try again.'; + + var error = new Error(message); + error.status = response.status; + error.body = text; + + console.error('[passkey] ' + method + ' ' + endpoint + ' failed', { + 'status': response.status, + 'body': text.slice(0, 2000) + }); + + throw error; + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Builds an absolute URL for a site path + * + * @param {string} path The path, relative to the site root + * + * @return {string} + */ + function siteUrl(path) { + var form = document.querySelector('[data-passkey-site-url]'); + var base = form ? form.getAttribute('data-passkey-site-url') : '/'; + return (base || '/').replace(/\/+$/, '') + '/' + path; + } + + // -------------------------------------------------------------------------- + + /** + * Registers a new passkey against the logged in account + * + * @param {Object} settings Optionally carries a label for the new passkey + * + * @return {Promise} + */ + function register(settings) { + settings = settings || {}; + + return post('register', {}) + .then(function(data) { + return create(data.options); + }) + .then(function(credential) { + return post('attest', { + 'label': settings.label || null, + 'credential': credential + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Signs in with a passkey + * + * @param {Object} settings Mediation, remember-me, and return URL + * + * @return {Promise} + */ + function login(settings) { + settings = settings || {}; + + var signal = null; + + if (settings.conditional) { + conditionalAbortController = new AbortController(); + signal = conditionalAbortController.signal; + } + + return post('challenge', {}) + .then(function(data) { + return get(data.options, { + // Only the conditional path names a mediation; the modal one takes the default + 'mediation': settings.conditional ? 'conditional' : null, + 'signal': signal + }); + }) + .then(function(credential) { + return post('assert', { + 'credential': credential, + 'remember': !!settings.remember, + 'return_to': settings.returnTo || null + }); + }) + .then(function(data) { + window.location.assign(data.redirect); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Abandons any conditional request which is waiting in the background + * + * Called before starting an explicit ceremony, because a browser will only + * entertain one outstanding request at a time and the newest should win. + * + * @return {void} + */ + function abortConditional() { + if (conditionalAbortController) { + conditionalAbortController.abort(); + conditionalAbortController = null; + } + } + + // -------------------------------------------------------------------------- + + /** + * Whether an error is the user declining, rather than something going wrong + * + * @param {Error} error The error to test + * + * @return {boolean} + */ + function isCancellation(error) { + + if (!error || error.passkeyRefused) { + return false; + } + + return error.name === 'NotAllowedError' || error.name === 'AbortError' || + error.message === 'cancelled'; + } + + // -------------------------------------------------------------------------- + + /** + * Turns an error into something a user can act on + * + * The browser reports ceremony failures as DOMExceptions whose messages are + * written for developers, so the well-known ones are named explicitly. + * + * @param {Error} error The error to describe + * + * @return {string} + */ + function describeError(error) { + + if (error && error.passkeyUnfocused) { + return 'The passkey prompt could not open because this page was not in focus. ' + + 'Click anywhere on the page, then try again.'; + } + + if (error && error.passkeyTimedOut) { + return 'The passkey prompt never opened. A password manager or browser extension ' + + 'may be blocking it; please try again, or sign in with your password.'; + } + + if (error && error.passkeyRefused) { + return 'Your browser could not open the passkey prompt. This is often a browser ' + + 'extension interfering; please try again, or sign in with your password.'; + } + + switch (error && error.name) { + + case 'InvalidStateError': + return 'This device already has a passkey for your account.'; + + case 'SecurityError': + return 'Passkeys need a secure (HTTPS) connection to this site.'; + + case 'NotSupportedError': + case 'ConstraintError': + return 'This device cannot create the kind of passkey this site asks for.'; + + case 'UnknownError': + return 'This device could not complete the request. Please try again.'; + + default: + return (error && error.message) + ? error.message + : 'Sorry, something went wrong. Please try again.'; + } + } + + // -------------------------------------------------------------------------- + + /** + * Marks a control as waiting on the browser + * + * A ceremony can sit for a long time - the user may be reaching for their phone - + * so the control says what it is doing rather than just going dim. + * + * @param {Element} control The control to mark + * @param {boolean} busy Whether the ceremony is running + * + * @return {void} + */ + function setBusy(control, busy) { + + if (busy) { + control.setAttribute('data-passkey-idle-label', control.innerHTML); + control.innerHTML = control.getAttribute('data-passkey-busy-label') || + 'Waiting for your passkey…'; + } else if (control.hasAttribute('data-passkey-idle-label')) { + control.innerHTML = control.getAttribute('data-passkey-idle-label'); + control.removeAttribute('data-passkey-idle-label'); + } + + control.disabled = busy; + control.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + + // -------------------------------------------------------------------------- + + /** + * Shows an error near the control which produced it + * + * @param {Element} origin The control the user interacted with + * @param {string} message The message to show + * + * @return {void} + */ + function showError(origin, message) { + var target = origin && origin.closest ? origin.closest('div, form, td') : null; + var element = (target || document).querySelector('[data-passkey-error]'); + + if (element) { + element.textContent = message; + element.hidden = false; + } + } + + // -------------------------------------------------------------------------- + + /** + * Hides any error previously shown near a control + * + * @param {Element} origin The control the user interacted with + * + * @return {void} + */ + function clearError(origin) { + var target = origin && origin.closest ? origin.closest('div, form, td') : null; + var element = (target || document).querySelector('[data-passkey-error]'); + + if (element) { + element.hidden = true; + } + } + + // -------------------------------------------------------------------------- + + /** + * Wires up the login button + * + * @return {void} + */ + function bindLoginButtons() { + var buttons = document.querySelectorAll('[data-passkey-login]'); + + Array.prototype.forEach.call(buttons, function(button) { + + /** + * The button may be wrapped in a block carrying a separator; reveal the two + * together so a rule never appears without the button it introduces. + */ + var block = button.closest ? button.closest('[data-passkey-block]') : null; + + if (block) { + block.hidden = false; + } + + button.hidden = false; + + button.addEventListener('click', function() { + + clearError(button); + abortConditional(); + + setBusy(button, true); + + var form = document.getElementById('login-form'); + var remember = form ? form.querySelector('[name="remember"]') : null; + + login({ + 'remember': remember ? remember.checked : false, + 'returnTo': button.getAttribute('data-passkey-return-to') || + (form ? form.getAttribute('data-passkey-return-to') : null) + }).catch(function(error) { + setBusy(button, false); + if (!isCancellation(error)) { + showError(button, describeError(error)); + } + }); + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Starts a conditional request so the identifier field can offer a passkey + * + * @return {void} + */ + function bindConditional() { + var field = document.querySelector('[data-passkey-conditional]'); + + if (!field) { + return; + } + + isConditionalMediationAvailable().then(function(available) { + + if (!available) { + return; + } + + var form = document.getElementById('login-form'); + + login({ + 'conditional': true, + 'returnTo': form ? form.getAttribute('data-passkey-return-to') : null + }).catch(function() { + // A cancelled or superseded conditional request is not worth reporting + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Wires up the "add a passkey" buttons + * + * @return {void} + */ + function bindRegisterButtons() { + var buttons = document.querySelectorAll('[data-passkey-register]'); + + if (!buttons.length) { + return; + } + + Array.prototype.forEach.call(buttons, function(button) { + + var isNudge = button.hasAttribute('data-passkey-nudge'); + + /** + * On the nudge, a device with nothing to enrol should not be asked at all, + * so the browser answers on the user's behalf and moves them along. + */ + var ready = isNudge ? isPlatformAuthenticatorAvailable() : Promise.resolve(isSupported()); + + ready.then(function(available) { + + if (!available) { + if (isNudge) { + var skip = document.getElementById('passkey-nudge-skip'); + if (skip) { + skip.submit(); + } + } else { + var notice = document.querySelector('[data-passkey-unsupported]'); + if (notice) { + notice.hidden = false; + } + } + return; + } + + button.hidden = false; + + button.addEventListener('click', function() { + + clearError(button); + setBusy(button, true); + + var labelField = document.querySelector('[data-passkey-label]'); + + register({'label': labelField ? labelField.value : null}) + .then(function() { + window.location.assign( + button.getAttribute('data-passkey-redirect') || window.location.href + ); + }) + .catch(function(error) { + setBusy(button, false); + if (!isCancellation(error)) { + showError(button, describeError(error)); + } + }); + }); + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Wires up the declarative controls the MFA driver renders + * + * The driver supplies options and a target; the ceremony runs here, the result is + * written into a hidden input, and the surrounding form is submitted. That keeps + * the driver free of JavaScript of its own. + * + * @return {void} + */ + function bindDriverControls() { + + var controls = document.querySelectorAll('[data-passkey-assert][data-options], [data-passkey-create][data-options]'); + + Array.prototype.forEach.call(controls, function(control) { + + if (!isSupported()) { + showError(control, 'This browser does not support passkeys.'); + return; + } + + control.hidden = false; + + control.addEventListener('click', function() { + + clearError(control); + control.disabled = true; + + var options; + + try { + options = JSON.parse(control.getAttribute('data-options')); + } catch (e) { + control.disabled = false; + showError(control, 'This request is malformed; please reload the page.'); + return; + } + + var ceremony = control.hasAttribute('data-passkey-create') + ? create(options) + : get(options, {}); + + ceremony + .then(function(credential) { + + var target = document.querySelector(control.getAttribute('data-target')); + var form = control.closest('form'); + + if (!target || !form) { + throw new Error('unexpected'); + } + + target.value = JSON.stringify(credential); + + var action = control.getAttribute('data-action'); + if (action) { + var field = form.querySelector('[name="action"]'); + if (field) { + field.value = action; + } + } + + form.submit(); + }) + .catch(function(error) { + control.disabled = false; + if (!isCancellation(error)) { + showError(control, describeError(error)); + } + }); + }); + }); + } + + // -------------------------------------------------------------------------- + + window.NAILS = window.NAILS || {}; + window.NAILS.PASSKEY = { + 'isSupported': isSupported, + 'isConditionalMediationAvailable': isConditionalMediationAvailable, + 'isPlatformAuthenticatorAvailable': isPlatformAuthenticatorAvailable, + 'toJSON': toJSON, + 'decodeCreationOptions': decodeCreationOptions, + 'decodeRequestOptions': decodeRequestOptions, + 'create': create, + 'get': get, + 'register': register, + 'login': login, + 'abortConditional': abortConditional + }; + + // -------------------------------------------------------------------------- + + /** + * Wires up every declarative control on the page + * + * @return {void} + */ + function boot() { + + if (!isSupported()) { + return; + } + + bindLoginButtons(); + bindConditional(); + bindRegisterButtons(); + bindDriverControls(); + } + + // -------------------------------------------------------------------------- + + /** + * DOMContentLoaded may already have fired by the time this runs - the asset is + * deferred, and a page may inject it later still - in which case waiting for the + * event would leave every control hidden. + */ + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } + +})(); diff --git a/assets/sass/styles.scss b/assets/sass/styles.scss index 7c8c2fee..5f693adf 100644 --- a/assets/sass/styles.scss +++ b/assets/sass/styles.scss @@ -12,3 +12,191 @@ } } } + +/* -------------------------------------------------------------- + Passkeys +-------------------------------------------------------------- */ + +.nails-auth { + + /** + * `.form__actions .btn` zeroes the bottom margin that `.btn--block` sets, so + * full-width buttons in a form's action area stack flush against each other. + * A row gap restores the rhythm without fighting that rule's specificity, and + * only applies between wrapped rows, so buttons sitting side by side keep the + * horizontal spacing they already have. + */ + .form__actions { + + row-gap: var(--spacing-md, 1rem); + + /** + * The framework gives every `.btn` in here a right margin, which is meant for + * buttons sitting side by side. A `.btn--block` never does, and the margin + * only makes its width depend on how deeply it happens to be nested. + */ + .btn--block { + margin-right: 0; + } + } + + [data-passkey-error] { + // Its own line, rather than squeezing in beside a button + flex-basis: 100%; + width: 100%; + } + + /** + * The alternative way in: a rule, then the passkey button. + * + * The block owns its spacing rather than leaning on `hr`'s own margin, because + * the preceding `.form__actions` contributes a bottom margin which collapses to + * the larger of the two and would leave the rule sitting off centre. + */ + .passkey-alternative { + + margin-bottom: 0; + + hr { + margin-top: 0; + margin-bottom: var(--spacing-md, 1rem); + } + + .btn { + margin-bottom: 0; + } + + [data-passkey-error] { + margin-top: var(--spacing-xs, 0.5rem); + } + } + + /** + * `form_open()` wraps the dismiss button so it carries a CSRF token, but that + * form is a flex item in `.form__actions` and would otherwise shrink to its + * content, leaving the `.btn--block` inside it anything but block. The same + * goes for the no-JS fallback, which is an inline element by default. + */ + .form__actions { + + // form_open() wraps a button so it carries a CSRF token + > form { + display: block; + flex-basis: 100%; + width: 100%; + margin: 0; + } + + /** + * The no-JS fallback needs the same full-width treatment, but deliberately + * without a `display` of its own: the browser hides `noscript` when scripting + * is enabled, and overriding that risks showing its contents as raw text. + */ + > noscript { + flex-basis: 100%; + width: 100%; + margin: 0; + } + } + + &.passkeys { + max-width: 900px; + + // The framework already makes this flex; it only needs spacing of its own + .form--inline { + gap: 0.5rem; + margin: 0; + } + + // Column headings are short labels; wrapping them reads as a mistake + thead th { + white-space: nowrap; + } + + .passkeys__date { + white-space: nowrap; + } + + /** + * A text input carries a browser default width of about twenty characters, + * which is what forces this table wider than a phone. Letting flex size it + * lets the column shrink with everything else; `min-width` has to go too, + * or the input refuses to shrink past that default. + */ + input[name="label"] { + // A small basis so the column can be narrow; it grows into what is spare + flex: 1 1 6rem; + min-width: 0; + } + + /** + * `.form--inline` is `row wrap`, which would drop the rename button onto a + * line of its own once the input grows to fill the cell. + */ + td:first-child .form--inline { + flex-wrap: nowrap; + width: 100%; + } + + /** + * Below this the four columns cannot fit - the rename input alone claims some + * twenty characters - and a table which scrolls sideways hides the controls + * people came for. Each row becomes a block instead, with every cell carrying + * the heading it lost. + */ + /** + * A last resort. If a long label or an awkward width still pushes the table + * past its container, it scrolls inside this rather than breaking the page. + */ + .passkeys__table { + overflow-x: auto; + } + + @media (max-width: 767px) { + + table, + tbody, + tr, + td { + display: block; + width: 100%; + } + + thead { + display: none; + } + + tr { + padding: var(--spacing-md, 1rem) 0; + border-bottom: 1px solid var(--color-border, #d1d5db); + } + + td { + padding: 0.25rem 0; + text-align: left; + border: 0; + } + + td[data-label]::before { + content: attr(data-label) ": "; + font-weight: var(--font-weight-bold, 600); + } + + .passkeys__actions { + margin-top: 0.5rem; + } + } + } +} + +/** + * `.form__feedback--invalid` sets `display: block`, which beats the `hidden` + * attribute's user-agent rule, so an empty placeholder would hold a row open + * permanently. Deliberately unscoped: apps rendering the helper's markup in + * their own views need it too, and the same guard protects the block wrapper + * from any app style which gives it a `display`. + */ +[data-passkey-error][hidden], +[data-passkey-block][hidden] { + display: none; +} diff --git a/webpack.config.js b/webpack.config.js index 935e5ecf..0f74c5db 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,7 @@ const path = require('path'); module.exports = { entry: { 'admin': './assets/js/admin.js', + 'passkey': './assets/js/passkey.js', 'styles': './assets/js/styles.js', }, output: { From cf26e75c8ccd338721211437c80c69f49a1ed7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Thu, 10 Sep 2026 22:08:22 +0100 Subject: [PATCH 13/18] chore: Build assets Co-Authored-By: Claude Opus 5 --- assets/css/styles.min.css | 2 +- assets/js/passkey.min.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 assets/js/passkey.min.js diff --git a/assets/css/styles.min.css b/assets/css/styles.min.css index 6e1de735..aa3f2d69 100644 --- a/assets/css/styles.min.css +++ b/assets/css/styles.min.css @@ -1 +1 @@ -.nails-auth{max-width:500px}.nails-auth.forgotten-password .new-password{font-family:courier,monospace;font-size:1.5rem} +.nails-auth{max-width:500px}.nails-auth.forgotten-password .new-password{font-family:courier,monospace;font-size:1.5rem}.nails-auth .form__actions{row-gap:var(--spacing-md, 1rem)}.nails-auth .form__actions .btn--block{margin-right:0}.nails-auth [data-passkey-error]{flex-basis:100%;width:100%}.nails-auth .passkey-alternative{margin-bottom:0}.nails-auth .passkey-alternative hr{margin-top:0;margin-bottom:var(--spacing-md, 1rem)}.nails-auth .passkey-alternative .btn{margin-bottom:0}.nails-auth .passkey-alternative [data-passkey-error]{margin-top:var(--spacing-xs, 0.5rem)}.nails-auth .form__actions>form{display:block;flex-basis:100%;width:100%;margin:0}.nails-auth .form__actions>noscript{flex-basis:100%;width:100%;margin:0}.nails-auth.passkeys{max-width:900px}.nails-auth.passkeys .form--inline{gap:.5rem;margin:0}.nails-auth.passkeys thead th{white-space:nowrap}.nails-auth.passkeys .passkeys__date{white-space:nowrap}.nails-auth.passkeys input[name=label]{flex:1 1 6rem;min-width:0}.nails-auth.passkeys td:first-child .form--inline{flex-wrap:nowrap;width:100%}.nails-auth.passkeys .passkeys__table{overflow-x:auto}@media(max-width: 767px){.nails-auth.passkeys table,.nails-auth.passkeys tbody,.nails-auth.passkeys tr,.nails-auth.passkeys td{display:block;width:100%}.nails-auth.passkeys thead{display:none}.nails-auth.passkeys tr{padding:var(--spacing-md, 1rem) 0;border-bottom:1px solid var(--color-border, #d1d5db)}.nails-auth.passkeys td{padding:.25rem 0;text-align:left;border:0}.nails-auth.passkeys td[data-label]::before{content:attr(data-label) ": ";font-weight:var(--font-weight-bold, 600)}.nails-auth.passkeys .passkeys__actions{margin-top:.5rem}}[data-passkey-error][hidden],[data-passkey-block][hidden]{display:none} diff --git a/assets/js/passkey.min.js b/assets/js/passkey.min.js new file mode 100644 index 00000000..cbdc95f4 --- /dev/null +++ b/assets/js/passkey.min.js @@ -0,0 +1 @@ +(()=>{"use strict";!function(){var e=null;function t(){return"function"==typeof window.PublicKeyCredential&&!!(navigator.credentials&&navigator.credentials.create&&navigator.credentials.get)}function n(){return t()&&"function"==typeof window.PublicKeyCredential.isConditionalMediationAvailable?window.PublicKeyCredential.isConditionalMediationAvailable().catch(function(){return!1}):Promise.resolve(!1)}function r(){return t()&&"function"==typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().catch(function(){return!1}):Promise.resolve(!1)}function a(e){for(var t=e.replace(/-/g,"+").replace(/_/g,"/");t.length%4;)t+="=";for(var n=window.atob(t),r=new Uint8Array(n.length),a=0;a Date: Thu, 10 Sep 2026 22:08:22 +0100 Subject: [PATCH 14/18] test: Cover the passkey ceremonies against the real library The tests drive the real verification paths in lbuchs rather than mocking them. tests/Stub/WebAuthnFixture.php synthesises what an authenticator produces: a P-256 keypair from openssl, the COSE key, authenticator data, an fmt:none attestation object and client data, with assertions signed by the generated key. A minimal CBOR encoder sits alongside it, since the library only ships a decoder. That means a signature which verifies here verifies for the same reason a genuine one does, and the negative cases are genuine too: a forged signature, another key's assertion, a replayed counter, a missing user verification flag, a foreign user handle and a wrong origin are all rejected by the library's own code. No database and no network. Also covers the authenticator name lookup, the login-method signal round trip, the API's authentication gating, and that Routes::generate() is unchanged, since `auth/passkeys` relies on CI's controller mapping rather than a route. Co-Authored-By: Claude Opus 5 --- tests/Api/PasskeyControllerTest.php | 74 ++ tests/RoutesTest.php | 44 + .../Service/AuthenticationLoginMethodTest.php | 113 +++ tests/Service/PasskeyTest.php | 864 ++++++++++++++++++ tests/Stub/Cbor.php | 138 +++ tests/Stub/CborBytes.php | 13 + tests/Stub/CborMap.php | 17 + tests/Stub/WebAuthnFixture.php | 269 ++++++ 8 files changed, 1532 insertions(+) create mode 100644 tests/Api/PasskeyControllerTest.php create mode 100644 tests/RoutesTest.php create mode 100644 tests/Service/AuthenticationLoginMethodTest.php create mode 100644 tests/Service/PasskeyTest.php create mode 100644 tests/Stub/Cbor.php create mode 100644 tests/Stub/CborBytes.php create mode 100644 tests/Stub/CborMap.php create mode 100644 tests/Stub/WebAuthnFixture.php diff --git a/tests/Api/PasskeyControllerTest.php b/tests/Api/PasskeyControllerTest.php new file mode 100644 index 00000000..2788b356 --- /dev/null +++ b/tests/Api/PasskeyControllerTest.php @@ -0,0 +1,74 @@ + + */ + public static function publicMethods(): array + { + return [ + 'challenge' => ['challenge'], + 'assert' => ['assert'], + 'challenge, cased' => ['Challenge'], + 'assert, cased' => ['ASSERT'], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * @return array + */ + public static function protectedMethods(): array + { + return [ + 'index' => ['index'], + 'register' => ['register'], + 'attest' => ['attest'], + 'rename' => ['rename'], + 'revoke' => ['revoke'], + 'anything else' => ['somethingUnknown'], + 'no method given' => [''], + ]; + } + + // -------------------------------------------------------------------------- + + #[DataProvider('publicMethods')] + public function test_the_login_endpoints_are_reachable_when_logged_out(string $sMethod): void + { + self::assertTrue(Passkey::isAuthenticated('POST', $sMethod)); + } + + // -------------------------------------------------------------------------- + + /** + * These tests run without a session, so isLoggedIn() is false; anything which is + * not on the public list must therefore be refused. + */ + #[DataProvider('protectedMethods')] + public function test_the_management_endpoints_are_refused_when_logged_out(string $sMethod): void + { + self::assertFalse(Passkey::isAuthenticated('POST', $sMethod)); + } + + // -------------------------------------------------------------------------- + + public function test_the_public_list_is_exactly_the_two_login_endpoints(): void + { + self::assertSame(['challenge', 'assert'], Passkey::PUBLIC_METHODS); + } +} diff --git a/tests/RoutesTest.php b/tests/RoutesTest.php new file mode 100644 index 00000000..b10ad694 --- /dev/null +++ b/tests/RoutesTest.php @@ -0,0 +1,44 @@ +` directly, and the API router has its + * own dispatch. This pins that no route was added by accident, because a stray + * pattern here would shadow the existing auth URLs. + * + * @covers \Nails\Auth\Routes + */ +class RoutesTest extends TestCase +{ + public function test_the_generated_routes_are_unchanged(): void + { + self::assertSame( + [ + 'auth/override/login_as/(.+)/(.+)' => 'auth/sessionOverride/login_as', + 'auth/password/forgotten(/(.+))?' => 'auth/PasswordForgotten/$2', + 'auth/password/reset/(\d+)/(.+)' => 'auth/PasswordReset/$1/$2', + 'auth/mfa/device/(\d+)/(.+)/(.+)(/(.+))?' => 'auth/MfaDevice', + 'auth/mfa/question/(\d+)/(.+)/(.+)(/(.+))?' => 'auth/MfaQuestion', + ], + Routes::generate() + ); + } + + // -------------------------------------------------------------------------- + + public function test_no_route_shadows_the_passkey_urls(): void + { + foreach (array_keys(Routes::generate()) as $sPattern) { + self::assertSame( + 0, + preg_match('#^' . $sPattern . '$#', 'auth/passkeys'), + sprintf('The route "%s" would capture auth/passkeys', $sPattern) + ); + } + } +} diff --git a/tests/Service/AuthenticationLoginMethodTest.php b/tests/Service/AuthenticationLoginMethodTest.php new file mode 100644 index 00000000..c1cb8f27 --- /dev/null +++ b/tests/Service/AuthenticationLoginMethodTest.php @@ -0,0 +1,113 @@ +oService = $oService; + + $this->oService->clearLoginMethod(); + } + + // -------------------------------------------------------------------------- + + protected function tearDown(): void + { + $this->oService->clearLoginMethod(); + } + + // -------------------------------------------------------------------------- + + public function test_there_is_no_signal_until_a_login_records_one(): void + { + self::assertNull($this->oService->getLoginMethod()); + self::assertFalse($this->oService->isLoginUserVerified()); + } + + // -------------------------------------------------------------------------- + + public function test_a_recorded_signal_reads_back_intact(): void + { + $iBefore = time(); + + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 42, true); + + $oSignal = $this->oService->getLoginMethod(); + + self::assertNotNull($oSignal); + self::assertSame(Authentication::LOGIN_METHOD_PASSKEY, $oSignal->method); + self::assertSame(42, $oSignal->user_id); + self::assertTrue($oSignal->user_verified); + self::assertGreaterThanOrEqual($iBefore, $oSignal->at); + } + + // -------------------------------------------------------------------------- + + /** + * A password login is never user-verified in the WebAuthn sense, so it must not + * be mistaken for one which can stand in for a second factor. + */ + public function test_a_password_login_is_not_recorded_as_user_verified(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSWORD, 42); + + self::assertSame( + Authentication::LOGIN_METHOD_PASSWORD, + $this->oService->getLoginMethod()->method + ); + self::assertFalse($this->oService->isLoginUserVerified()); + } + + // -------------------------------------------------------------------------- + + public function test_a_user_verified_signal_is_reported_as_such(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 7, true); + + self::assertTrue($this->oService->isLoginUserVerified()); + } + + // -------------------------------------------------------------------------- + + public function test_recording_a_second_login_replaces_the_first(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 7, true); + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSWORD, 8); + + $oSignal = $this->oService->getLoginMethod(); + + self::assertSame(Authentication::LOGIN_METHOD_PASSWORD, $oSignal->method); + self::assertSame(8, $oSignal->user_id); + self::assertFalse($oSignal->user_verified); + } + + // -------------------------------------------------------------------------- + + public function test_clearing_the_signal_removes_it(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 7, true); + $this->oService->clearLoginMethod(); + + self::assertNull($this->oService->getLoginMethod()); + self::assertFalse($this->oService->isLoginUserVerified()); + } +} diff --git a/tests/Service/PasskeyTest.php b/tests/Service/PasskeyTest.php new file mode 100644 index 00000000..bd6ce9d4 --- /dev/null +++ b/tests/Service/PasskeyTest.php @@ -0,0 +1,864 @@ + */ + private array $aConfig = []; + + // -------------------------------------------------------------------------- + + protected function setUp(): void + { + /** @var Passkey $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + $this->oService = $oService; + + foreach ([ + 'BASE_URL', + 'SECURE_BASE_URL', + 'APP_NAME', + Passkey::CONFIG_RP_ID, + Passkey::CONFIG_ALLOWED_ORIGINS, + Passkey::CONFIG_AUTHENTICATORS, + ] as $sKey) { + $this->aConfig[$sKey] = Config::get($sKey); + } + + Config::set('BASE_URL', self::ORIGIN . '/'); + Config::set('SECURE_BASE_URL', self::ORIGIN . '/'); + Config::set('APP_NAME', 'Test App'); + Config::set(Passkey::CONFIG_RP_ID, null); + Config::set(Passkey::CONFIG_ALLOWED_ORIGINS, null); + Config::set(Passkey::CONFIG_AUTHENTICATORS, null); + } + + // -------------------------------------------------------------------------- + + protected function tearDown(): void + { + foreach ($this->aConfig as $sKey => $mValue) { + Config::set($sKey, $mValue); + } + } + + // -------------------------------------------------------------------------- + + private function user(int $iId = 1): User + { + return new User((object) [ + 'id' => $iId, + 'email' => 'user' . $iId . '@example.com', + 'username' => 'user' . $iId, + 'first_name' => 'Test', + 'last_name' => 'User', + 'name' => 'Test User', + ]); + } + + // -------------------------------------------------------------------------- + // Configuration + // -------------------------------------------------------------------------- + + public function test_the_rp_id_defaults_to_the_host_of_the_base_url(): void + { + self::assertSame(self::RP_ID, $this->oService->getRpId()); + } + + // -------------------------------------------------------------------------- + + public function test_the_rp_id_can_be_overridden_by_config(): void + { + Config::set(Passkey::CONFIG_RP_ID, 'Sub.Example.Com'); + + // Lowercased: the RP ID is compared against a host, which is case insensitive + self::assertSame('sub.example.com', $this->oService->getRpId()); + } + + // -------------------------------------------------------------------------- + + public function test_the_rp_name_falls_back_to_the_rp_id(): void + { + self::assertSame('Test App', $this->oService->getRpName()); + + Config::set('APP_NAME', ''); + + self::assertSame(self::RP_ID, $this->oService->getRpName()); + } + + // -------------------------------------------------------------------------- + + public function test_the_allowed_origins_cover_both_base_urls_without_duplicates(): void + { + Config::set('SECURE_BASE_URL', 'https://secure.example.com/'); + + self::assertSame( + ['https://example.com', 'https://secure.example.com'], + $this->oService->getAllowedOrigins() + ); + } + + // -------------------------------------------------------------------------- + + public function test_the_allowed_origins_can_be_extended_by_config(): void + { + Config::set(Passkey::CONFIG_ALLOWED_ORIGINS, ['https://app.example.com:8443/somewhere']); + + // Reduced to an origin: a path cannot form part of one + self::assertContains('https://app.example.com:8443', $this->oService->getAllowedOrigins()); + } + + // -------------------------------------------------------------------------- + + public function test_a_default_port_is_dropped_when_normalising_an_origin(): void + { + Config::set('BASE_URL', 'https://example.com:443/'); + + self::assertSame(['https://example.com'], $this->oService->getAllowedOrigins()); + } + + // -------------------------------------------------------------------------- + + /** + * The library's own origin check is a suffix match on the host alone, which a + * lookalike domain satisfies; this is the check that actually protects us. + */ + public function test_a_lookalike_host_is_not_an_allowed_origin(): void + { + $this->expectException(OriginNotAllowedException::class); + + $this->oService->assertOriginAllowed('https://evil-example.com'); + } + + // -------------------------------------------------------------------------- + + public function test_a_subdomain_of_an_allowed_origin_is_not_itself_allowed(): void + { + $this->expectException(OriginNotAllowedException::class); + + $this->oService->assertOriginAllowed('https://evil.example.com'); + } + + // -------------------------------------------------------------------------- + + public function test_a_downgraded_scheme_is_not_an_allowed_origin(): void + { + $this->expectException(OriginNotAllowedException::class); + + $this->oService->assertOriginAllowed('http://example.com'); + } + + // -------------------------------------------------------------------------- + + public function test_the_apps_own_origin_is_allowed(): void + { + $this->oService->assertOriginAllowed(self::ORIGIN); + + self::assertTrue(true); + } + + // -------------------------------------------------------------------------- + // User handles + // -------------------------------------------------------------------------- + + public function test_a_user_handle_is_stable_for_a_user(): void + { + self::assertSame( + $this->oService->deriveUserHandle($this->user(7)), + $this->oService->deriveUserHandle($this->user(7)) + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_user_handle_differs_between_users(): void + { + self::assertNotSame( + $this->oService->deriveUserHandle($this->user(7)), + $this->oService->deriveUserHandle($this->user(8)) + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_user_handle_is_thirty_two_bytes(): void + { + $sHandle = $this->oService->deriveUserHandle($this->user()); + + self::assertSame(32, strlen($this->oService->base64UrlDecode($sHandle))); + + // It must also fit the column it is stored in + self::assertLessThanOrEqual(64, strlen($sHandle)); + } + + // -------------------------------------------------------------------------- + // Ceremony options + // -------------------------------------------------------------------------- + + public function test_registration_options_describe_the_relying_party_and_the_user(): void + { + $oResult = $this->oService->buildRegistrationOptions($this->user()); + $oOptions = $oResult->options; + + self::assertSame(self::RP_ID, $oOptions->rp->id); + self::assertSame('Test App', $oOptions->rp->name); + self::assertSame('user1@example.com', $oOptions->user->name); + self::assertSame('Test User', $oOptions->user->displayName); + self::assertSame( + $this->oService->deriveUserHandle($this->user()), + $oOptions->user->id->jsonSerialize() + ); + } + + // -------------------------------------------------------------------------- + + public function test_registration_options_ask_for_a_discoverable_credential_without_attestation(): void + { + $oOptions = $this->oService->buildRegistrationOptions($this->user())->options; + + self::assertSame('preferred', $oOptions->authenticatorSelection->residentKey); + self::assertSame('preferred', $oOptions->authenticatorSelection->userVerification); + self::assertSame('none', $oOptions->attestation); + self::assertTrue($oOptions->extensions->credProps); + self::assertSame(Passkey::TIMEOUT * 1000, $oOptions->timeout); + } + + // -------------------------------------------------------------------------- + + public function test_registration_options_exclude_the_credentials_already_registered(): void + { + $sExisting = $this->oService->base64UrlEncode('an-existing-credential'); + + $oOptions = $this->oService->buildRegistrationOptions($this->user(), [$sExisting])->options; + + self::assertCount(1, $oOptions->excludeCredentials); + self::assertSame($sExisting, $oOptions->excludeCredentials[0]->id->jsonSerialize()); + } + + // -------------------------------------------------------------------------- + + public function test_each_ceremony_mints_a_fresh_challenge(): void + { + self::assertNotSame( + $this->oService->buildRegistrationOptions($this->user())->challenge, + $this->oService->buildRegistrationOptions($this->user())->challenge + ); + } + + // -------------------------------------------------------------------------- + + public function test_authentication_options_without_an_allow_list_are_discoverable(): void + { + $oOptions = $this->oService->buildAuthenticationOptions()->options; + + self::assertObjectNotHasProperty('allowCredentials', $oOptions); + self::assertSame('required', $oOptions->userVerification); + self::assertSame(self::RP_ID, $oOptions->rpId); + } + + // -------------------------------------------------------------------------- + + public function test_authentication_options_carry_the_allow_list_they_are_given(): void + { + $sId = $this->oService->base64UrlEncode('a-credential'); + + $oOptions = $this->oService + ->buildAuthenticationOptions([$sId], Passkey::UV_PREFERRED) + ->options; + + self::assertCount(1, $oOptions->allowCredentials); + self::assertSame($sId, $oOptions->allowCredentials[0]->id->jsonSerialize()); + self::assertSame('preferred', $oOptions->userVerification); + } + + // -------------------------------------------------------------------------- + // Registration verification + // -------------------------------------------------------------------------- + + public function test_a_genuine_registration_is_accepted_and_described(): void + { + $oFixture = new WebAuthnFixture(null, hex2bin('0102030405060708090a0b0c0d0e0f10')); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $oResult = $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT + | WebAuthnFixture::FLAG_USER_VERIFIED + | WebAuthnFixture::FLAG_BACKUP_ELIGIBLE + | WebAuthnFixture::FLAG_BACKED_UP + | WebAuthnFixture::FLAG_ATTESTED_DATA + ), + $sChallenge + ); + + self::assertSame($oFixture->getCredentialIdBase64Url(), $oResult->credential_id); + self::assertSame(trim($oFixture->getPublicKeyPem()), trim($oResult->public_key)); + self::assertSame('none', $oResult->attestation_format); + self::assertSame('01020304-0506-0708-090a-0b0c0d0e0f10', $oResult->aaguid); + self::assertSame(['internal', 'hybrid'], $oResult->transports); + self::assertTrue($oResult->is_discoverable); + self::assertTrue($oResult->is_backup_eligible); + self::assertTrue($oResult->is_backed_up); + self::assertTrue($oResult->user_verified); + } + + // -------------------------------------------------------------------------- + + /** + * `fmt: none` authenticators zero the AAGUID out; that is "withheld", not an ID + * made of zeroes, so it is stored as unknown. + */ + public function test_a_withheld_aaguid_is_recorded_as_unknown(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $oResult = $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, self::ORIGIN, self::RP_ID), + $sChallenge + ); + + self::assertNull($oResult->aaguid); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_answering_a_different_challenge_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + + $sAnswered = $this->oService->buildRegistrationOptions($this->user())->challenge; + $sExpected = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sAnswered, self::ORIGIN, self::RP_ID), + $sExpected + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_from_another_origin_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(OriginNotAllowedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, 'https://evil.example.net', self::RP_ID), + $sChallenge + ); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_presented_as_a_registration_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $aResponse = $oFixture->createRegistrationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_ATTESTED_DATA, + 0, + ['type' => 'webauthn.get'] + ); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration($aResponse, $sChallenge); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_for_a_different_relying_party_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, self::ORIGIN, 'someone-else.example.com'), + $sChallenge + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_without_user_presence_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_ATTESTED_DATA + ), + $sChallenge + ); + } + + // -------------------------------------------------------------------------- + // Authentication verification + // -------------------------------------------------------------------------- + + /** + * @return array{0: WebAuthnFixture, 1: string, 2: string} + */ + private function registered(): array + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $oResult = $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, self::ORIGIN, self::RP_ID), + $sChallenge + ); + + return [$oFixture, $oResult->public_key, $this->oService->deriveUserHandle($this->user())]; + } + + // -------------------------------------------------------------------------- + + public function test_a_genuine_assertion_is_accepted_and_reports_its_counter(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 42, + $sHandle + ), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + + self::assertSame(42, $iCount); + } + + // -------------------------------------------------------------------------- + + public function test_a_forged_signature_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + $aResponse = $oFixture->createAuthenticationResponse($sChallenge, self::ORIGIN, self::RP_ID); + + // Flip a byte of the signature; everything else stays genuine + $sSignature = $this->oService->base64UrlDecode($aResponse['response']['signature']); + $sSignature[10] = chr(ord($sSignature[10]) ^ 0xFF); + $aResponse['response']['signature'] = $this->oService->base64UrlEncode($sSignature); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication($aResponse, $sChallenge, $sPem, 0, $sHandle, true); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_signed_by_another_key_is_rejected(): void + { + [, $sPem, $sHandle] = $this->registered(); + + $oImposter = new WebAuthnFixture(); + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication( + $oImposter->createAuthenticationResponse($sChallenge, self::ORIGIN, self::RP_ID), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + } + + // -------------------------------------------------------------------------- + + /** + * A counter which has not moved is how a cloned authenticator gives itself away, + * and is also what a replayed assertion looks like. + */ + public function test_a_stale_signature_counter_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 5, + $sHandle + ), + $sChallenge, + $sPem, + 5, + $sHandle, + true + ); + } + + // -------------------------------------------------------------------------- + + /** + * Plenty of platform authenticators never implement a counter and always report + * zero; those must keep working rather than looking permanently stale. + */ + public function test_an_authenticator_which_keeps_no_counter_still_authenticates(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 0, + $sHandle + ), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + + self::assertSame(0, $iCount); + } + + // -------------------------------------------------------------------------- + + public function test_an_unverified_assertion_is_rejected_when_verification_is_required(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $aResponse = $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT, + 1, + $sHandle + ); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication($aResponse, $sChallenge, $sPem, 0, $sHandle, true); + } + + // -------------------------------------------------------------------------- + + public function test_an_unverified_assertion_is_accepted_as_a_second_factor(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT, + 3, + $sHandle + ), + $sChallenge, + $sPem, + 0, + $sHandle, + false + ); + + self::assertSame(3, $iCount); + } + + // -------------------------------------------------------------------------- + + /** + * The library does not read the user handle back, so this check lives in the + * service; without it a discoverable login could present somebody else's handle. + */ + public function test_an_assertion_carrying_another_users_handle_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $aResponse = $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 1, + $this->oService->deriveUserHandle($this->user(99)) + ); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication($aResponse, $sChallenge, $sPem, 0, $sHandle, true); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_without_a_user_handle_is_accepted(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 1, + null + ), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + + self::assertSame(1, $iCount); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_answering_a_different_challenge_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sAnswered = $this->oService->buildAuthenticationOptions()->challenge; + $sExpected = $this->oService->buildAuthenticationOptions()->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse($sAnswered, self::ORIGIN, self::RP_ID), + $sExpected, + $sPem, + 0, + $sHandle, + true + ); + } + + // -------------------------------------------------------------------------- + // Malformed input + // -------------------------------------------------------------------------- + + /** + * @return array}> + */ + public static function malformedResponses(): array + { + return [ + 'no response at all' => [[]], + 'response is not an array' => [['response' => 'nonsense']], + 'missing client data' => [['response' => ['signature' => 'AAAA', 'authenticatorData' => 'AAAA']]], + 'missing signature' => [['response' => ['clientDataJSON' => 'AAAA', 'authenticatorData' => 'AAAA']]], + 'empty client data' => [['response' => ['clientDataJSON' => '', 'authenticatorData' => 'AAAA', 'signature' => 'AAAA']]], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aResponse + */ + #[DataProvider('malformedResponses')] + public function test_a_malformed_assertion_is_reported_as_such(array $aResponse): void + { + $this->expectException(InvalidResponseException::class); + + $this->oService->verifyAuthentication($aResponse, 'AAAA', 'not-a-key', 0, 'handle', true); + } + + // -------------------------------------------------------------------------- + + public function test_client_data_which_is_not_json_is_reported_as_malformed(): void + { + $this->expectException(InvalidResponseException::class); + + $this->oService->verifyAuthentication( + [ + 'response' => [ + 'clientDataJSON' => $this->oService->base64UrlEncode('not json at all'), + 'authenticatorData' => 'AAAA', + 'signature' => 'AAAA', + ], + ], + 'AAAA', + 'not-a-key', + 0, + 'handle', + true + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_malformed_registration_is_reported_as_such(): void + { + $this->expectException(InvalidResponseException::class); + + $this->oService->verifyRegistration(['response' => ['clientDataJSON' => 'AAAA']], 'AAAA'); + } + + // -------------------------------------------------------------------------- + // Describing an authenticator + // -------------------------------------------------------------------------- + + public function test_a_known_aaguid_is_named(): void + { + // Verified against a live 1Password registration + self::assertSame( + '1Password', + $this->oService->getAuthenticatorName('bada5566-a7aa-401f-bd96-45619a55120d') + ); + } + + // -------------------------------------------------------------------------- + + public function test_aaguid_matching_ignores_case(): void + { + self::assertSame( + '1Password', + $this->oService->getAuthenticatorName('BADA5566-A7AA-401F-BD96-45619A55120D') + ); + } + + // -------------------------------------------------------------------------- + + public function test_an_unknown_or_absent_aaguid_is_not_named(): void + { + self::assertNull($this->oService->getAuthenticatorName('00000000-0000-0000-0000-000000000000')); + self::assertNull($this->oService->getAuthenticatorName(null)); + self::assertNull($this->oService->getAuthenticatorName('')); + } + + // -------------------------------------------------------------------------- + + /** + * The register is maintained by hand, so an app must be able to name an + * authenticator we have not heard of without waiting for a release. + */ + public function test_an_app_can_name_an_authenticator_by_config(): void + { + $sAaguid = '11111111-2222-3333-4444-555555555555'; + + self::assertNull($this->oService->getAuthenticatorName($sAaguid)); + + Config::set(Passkey::CONFIG_AUTHENTICATORS, [$sAaguid => 'Acme Key']); + + self::assertSame('Acme Key', $this->oService->getAuthenticatorName($sAaguid)); + } + + // -------------------------------------------------------------------------- + + public function test_an_app_can_override_a_listed_authenticator(): void + { + Config::set(Passkey::CONFIG_AUTHENTICATORS, [ + 'bada5566-a7aa-401f-bd96-45619a55120d' => 'Our Password Manager', + ]); + + self::assertSame( + 'Our Password Manager', + $this->oService->getAuthenticatorName('bada5566-a7aa-401f-bd96-45619a55120d') + ); + } + + // -------------------------------------------------------------------------- + + // -------------------------------------------------------------------------- + // Encoding + // -------------------------------------------------------------------------- + + public function test_base64url_survives_a_round_trip_of_arbitrary_bytes(): void + { + $sBinary = random_bytes(64); + + self::assertSame( + $sBinary, + $this->oService->base64UrlDecode($this->oService->base64UrlEncode($sBinary)) + ); + } + + // -------------------------------------------------------------------------- + + public function test_base64url_encoding_is_url_safe_and_unpadded(): void + { + $sEncoded = $this->oService->base64UrlEncode(hex2bin('fbff00')); + + self::assertStringNotContainsString('+', $sEncoded); + self::assertStringNotContainsString('/', $sEncoded); + self::assertStringNotContainsString('=', $sEncoded); + } +} diff --git a/tests/Stub/Cbor.php b/tests/Stub/Cbor.php new file mode 100644 index 00000000..d411e804 --- /dev/null +++ b/tests/Stub/Cbor.php @@ -0,0 +1,138 @@ + $aValue + */ + public static function map(array $aValue): CborMap + { + return new CborMap($aValue); + } + + // -------------------------------------------------------------------------- + + /** + * Encodes a value as CBOR + * + * @param mixed $mValue + */ + public static function encode($mValue): string + { + if ($mValue instanceof CborMap) { + return self::encodeMap($mValue->aValue); + + } elseif ($mValue instanceof CborBytes) { + return self::head(self::MAJOR_BYTE_STRING, strlen($mValue->sValue)) . $mValue->sValue; + + } elseif (is_int($mValue)) { + return $mValue >= 0 + ? self::head(self::MAJOR_UNSIGNED_INT, $mValue) + : self::head(self::MAJOR_NEGATIVE_INT, -1 - $mValue); + + } elseif (is_string($mValue)) { + return self::head(self::MAJOR_TEXT_STRING, strlen($mValue)) . $mValue; + + } elseif (is_array($mValue)) { + return array_is_list($mValue) + ? self::encodeArray($mValue) + : self::encodeMap($mValue); + } + + throw new InvalidArgumentException( + sprintf('Cannot CBOR encode a value of type %s', get_debug_type($mValue)) + ); + } + + // -------------------------------------------------------------------------- + + /** + * Encodes a map, preserving the order the keys were given in + * + * @param array $aValue + */ + public static function encodeMap(array $aValue): string + { + $sOut = self::head(self::MAJOR_MAP, count($aValue)); + + foreach ($aValue as $mKey => $mItem) { + $sOut .= self::encode($mKey) . self::encode($mItem); + } + + return $sOut; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aValue + */ + public static function encodeArray(array $aValue): string + { + $sOut = self::head(self::MAJOR_ARRAY, count($aValue)); + + foreach ($aValue as $mItem) { + $sOut .= self::encode($mItem); + } + + return $sOut; + } + + // -------------------------------------------------------------------------- + + /** + * Emits an item header: the major type, plus the shortest encoding of the value + */ + private static function head(int $iMajor, int $iValue): string + { + $iPrefix = $iMajor << 5; + + if ($iValue < 24) { + return chr($iPrefix | $iValue); + + } elseif ($iValue <= 0xFF) { + return chr($iPrefix | 24) . chr($iValue); + + } elseif ($iValue <= 0xFFFF) { + return chr($iPrefix | 25) . pack('n', $iValue); + + } elseif ($iValue <= 0xFFFFFFFF) { + return chr($iPrefix | 26) . pack('N', $iValue); + } + + return chr($iPrefix | 27) . pack('J', $iValue); + } +} diff --git a/tests/Stub/CborBytes.php b/tests/Stub/CborBytes.php new file mode 100644 index 00000000..912c3d7c --- /dev/null +++ b/tests/Stub/CborBytes.php @@ -0,0 +1,13 @@ + $aValue + */ + public function __construct(public readonly array $aValue) + { + } +} diff --git a/tests/Stub/WebAuthnFixture.php b/tests/Stub/WebAuthnFixture.php new file mode 100644 index 00000000..57a279ac --- /dev/null +++ b/tests/Stub/WebAuthnFixture.php @@ -0,0 +1,269 @@ + OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + + if ($oKey === false) { + throw new RuntimeException('Failed to generate a P-256 keypair: ' . openssl_error_string()); + } + + $this->oKey = $oKey; + $this->sCredentialId = $sCredentialId ?? random_bytes(32); + $this->sAaguid = $sAaguid ?? str_repeat("\x00", 16); + } + + // -------------------------------------------------------------------------- + + public function getCredentialId(): string + { + return $this->sCredentialId; + } + + // -------------------------------------------------------------------------- + + public function getCredentialIdBase64Url(): string + { + return self::base64UrlEncode($this->sCredentialId); + } + + // -------------------------------------------------------------------------- + + /** + * The credential's public key, in the PEM form the library hands back + */ + public function getPublicKeyPem(): string + { + $aDetails = openssl_pkey_get_details($this->oKey); + + if ($aDetails === false || !isset($aDetails['key'])) { + throw new RuntimeException('Failed to read the public key.'); + } + + return $aDetails['key']; + } + + // -------------------------------------------------------------------------- + + /** + * Builds a registration response, as PublicKeyCredential.toJSON() would render it + * + * @param array $aOverrides + * + * @return array + */ + public function createRegistrationResponse( + string $sChallengeB64, + string $sOrigin, + string $sRpId, + int $iFlags = self::FLAG_USER_PRESENT | self::FLAG_ATTESTED_DATA, + int $iSignCount = 0, + array $aOverrides = [] + ): array { + + $sClientDataJson = $this->buildClientDataJson('webauthn.create', $sChallengeB64, $sOrigin, $aOverrides); + + $sAuthData = $this->buildAuthenticatorData($sRpId, $iFlags, $iSignCount, true); + + $sAttestationObject = Cbor::encodeMap([ + 'fmt' => 'none', + 'attStmt' => Cbor::map([]), + 'authData' => Cbor::bytes($sAuthData), + ]); + + return [ + 'id' => $this->getCredentialIdBase64Url(), + 'rawId' => $this->getCredentialIdBase64Url(), + 'type' => 'public-key', + 'authenticatorAttachment' => 'platform', + 'clientExtensionResults' => ['credProps' => ['rk' => true]], + 'response' => [ + 'clientDataJSON' => self::base64UrlEncode($sClientDataJson), + 'attestationObject' => self::base64UrlEncode($sAttestationObject), + 'transports' => ['internal', 'hybrid'], + ], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Builds an authentication response, signed with this fixture's private key + * + * @param array $aOverrides + * + * @return array + */ + public function createAuthenticationResponse( + string $sChallengeB64, + string $sOrigin, + string $sRpId, + int $iFlags = self::FLAG_USER_PRESENT | self::FLAG_USER_VERIFIED, + int $iSignCount = 1, + ?string $sUserHandle = null, + array $aOverrides = [] + ): array { + + $sClientDataJson = $this->buildClientDataJson('webauthn.get', $sChallengeB64, $sOrigin, $aOverrides); + + $sAuthData = $this->buildAuthenticatorData($sRpId, $iFlags, $iSignCount, false); + + // Exactly what the spec signs: authenticatorData || SHA-256(clientDataJSON) + $sSignature = $this->sign($sAuthData . hash('sha256', $sClientDataJson, true)); + + return [ + 'id' => $this->getCredentialIdBase64Url(), + 'rawId' => $this->getCredentialIdBase64Url(), + 'type' => 'public-key', + 'authenticatorAttachment' => 'platform', + 'clientExtensionResults' => [], + 'response' => [ + 'clientDataJSON' => self::base64UrlEncode($sClientDataJson), + 'authenticatorData' => self::base64UrlEncode($sAuthData), + 'signature' => self::base64UrlEncode($sSignature), + 'userHandle' => $sUserHandle, + ], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Signs data with the fixture's private key, as the authenticator would + */ + public function sign(string $sData): string + { + $sSignature = ''; + + if (!openssl_sign($sData, $sSignature, $this->oKey, OPENSSL_ALGO_SHA256)) { + throw new RuntimeException('Failed to sign: ' . openssl_error_string()); + } + + return $sSignature; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aOverrides + */ + private function buildClientDataJson( + string $sType, + string $sChallengeB64, + string $sOrigin, + array $aOverrides = [] + ): string { + + return (string) json_encode(array_merge([ + 'type' => $sType, + 'challenge' => $sChallengeB64, + 'origin' => $sOrigin, + 'crossOrigin' => false, + ], $aOverrides)); + } + + // -------------------------------------------------------------------------- + + /** + * Assembles authenticator data + * + * https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data + */ + private function buildAuthenticatorData( + string $sRpId, + int $iFlags, + int $iSignCount, + bool $bIncludeAttestedData + ): string { + + $sOut = hash('sha256', $sRpId, true) + . chr($iFlags) + . pack('N', $iSignCount); + + if ($bIncludeAttestedData) { + $sOut .= $this->sAaguid + . pack('n', strlen($this->sCredentialId)) + . $this->sCredentialId + . $this->buildCoseKey(); + } + + return $sOut; + } + + // -------------------------------------------------------------------------- + + /** + * Encodes the public key as a COSE_Key, the form the authenticator reports + */ + private function buildCoseKey(): string + { + $aDetails = openssl_pkey_get_details($this->oKey); + + if ($aDetails === false || !isset($aDetails['ec']['x'], $aDetails['ec']['y'])) { + throw new RuntimeException('Failed to read the EC coordinates.'); + } + + return Cbor::encodeMap([ + self::COSE_KTY => self::KTY_EC2, + self::COSE_ALG => self::ALG_ES256, + self::COSE_CRV => self::CRV_P256, + // Coordinates are fixed width; OpenSSL drops leading zero bytes + self::COSE_X => Cbor::bytes(str_pad($aDetails['ec']['x'], 32, "\x00", STR_PAD_LEFT)), + self::COSE_Y => Cbor::bytes(str_pad($aDetails['ec']['y'], 32, "\x00", STR_PAD_LEFT)), + ]); + } + + // -------------------------------------------------------------------------- + + public static function base64UrlEncode(string $sBinary): string + { + return rtrim(strtr(base64_encode($sBinary), '+/', '-_'), '='); + } +} From 826e2c2635929220f85bb775b3e0473324059c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Thu, 10 Sep 2026 22:08:28 +0100 Subject: [PATCH 15/18] chore: Update IDE configuration Written by PhpStorm after the composer update: the new vendor exclusions and include paths, plus a package prefix on the test source folder. Co-Authored-By: Claude Opus 5 --- .idea/module-auth.iml | 3 +++ .idea/php.xml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.idea/module-auth.iml b/.idea/module-auth.iml index f9bf76d1..35220ee3 100644 --- a/.idea/module-auth.iml +++ b/.idea/module-auth.iml @@ -3,6 +3,7 @@ + @@ -106,6 +107,8 @@ + + diff --git a/.idea/php.xml b/.idea/php.xml index dbff9f6d..3cb79bf4 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -118,6 +118,8 @@ + + From df50e3c8a64ce44f54f4ef866aee83938deb1edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Fri, 11 Sep 2026 09:21:35 +0100 Subject: [PATCH 16/18] fix: Use the admin alert class on the settings panel The Relying Party panel used `alert--info`, which is the frontend convention. Admin's stylesheet only defines `alert-info`, so the panel rendered with no background, border or colour at all. Co-Authored-By: Claude Opus 5 --- admin/views/Settings/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/views/Settings/index.php b/admin/views/Settings/index.php index 43a7fd12..f2be345f 100644 --- a/admin/views/Settings/index.php +++ b/admin/views/Settings/index.php @@ -63,7 +63,7 @@ */ ?> -
+

Relying Party ID: getRpId())?> From c4087efd7f98afc9d01b90e83db79fb1f3215ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Fri, 11 Sep 2026 09:21:50 +0100 Subject: [PATCH 17/18] fix: Sort passkeys deterministically The model set no default sort column, so `getByUserId()` emitted no ORDER BY and row order was whatever the storage engine happened to return. Sorting by `id` keeps the list in the order the passkeys were added, which is what the view implies. Co-Authored-By: Claude Opus 5 --- src/Model/User/Passkey.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Model/User/Passkey.php b/src/Model/User/Passkey.php index cddd4af2..97cf3b83 100644 --- a/src/Model/User/Passkey.php +++ b/src/Model/User/Passkey.php @@ -17,9 +17,10 @@ */ class Passkey extends Base { - const TABLE = NAILS_DB_PREFIX . 'user_passkey'; - const RESOURCE_NAME = 'UserPasskey'; - const RESOURCE_PROVIDER = Constants::MODULE_SLUG; + const TABLE = NAILS_DB_PREFIX . 'user_passkey'; + const RESOURCE_NAME = 'UserPasskey'; + const RESOURCE_PROVIDER = Constants::MODULE_SLUG; + const DEFAULT_SORT_COLUMN = 'id'; // -------------------------------------------------------------------------- From 2fc678f8239f58d2963ea654735b8ff80b84d804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?= Date: Fri, 11 Sep 2026 09:45:04 +0100 Subject: [PATCH 18/18] fix: Restore covariant return type on Routes::generate() Pre-existing drift on this branch (unrelated to the passkeys work): Nails\Common\Interfaces\RouteGenerator::generate() declares an `array` return type, but this method didn't, which PHP only flags once the class is actually loaded. It stayed dormant until tests/RoutesTest.php exercised Routes::generate() directly, which crashed PHPUnit outright. develop already declares the return type correctly. Co-Authored-By: Claude Sonnet 5 --- src/Routes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routes.php b/src/Routes.php index d6b69c85..63d3bf23 100644 --- a/src/Routes.php +++ b/src/Routes.php @@ -20,7 +20,7 @@ class Routes implements RouteGenerator * Returns an array of routes for this module * @return array */ - public static function generate() + public static function generate(): array { return [ 'auth/override/login_as/(.+)/(.+)' => 'auth/sessionOverride/login_as',