diff --git a/Api/AdminTfa/AuthenticateInterface.php b/Api/AdminTfa/AuthenticateInterface.php
new file mode 100644
index 0000000..79e9d97
--- /dev/null
+++ b/Api/AdminTfa/AuthenticateInterface.php
@@ -0,0 +1,34 @@
+setName('security:tfa:passkey:reset-all');
+ $this->setDescription('Reset passkey 2FA configuration for all admin users');
+ $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $users = $this->userCollectionFactory->create();
+ $resetCount = 0;
+ $affectedUsers = [];
+
+ foreach ($users as $user) {
+ $userId = (int) $user->getId();
+ foreach (self::PROVIDER_CODES as $providerCode) {
+ $config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
+ if (!empty($config) && isset($config['registration'])) {
+ $affectedUsers[$userId] = $user->getUserName();
+ }
+ }
+ }
+
+ if (empty($affectedUsers)) {
+ $output->writeln('No admin users have passkey 2FA configured. ');
+ return Command::SUCCESS;
+ }
+
+ $output->writeln(sprintf(
+ 'Found %d admin user(s) with passkey 2FA configured: ',
+ count($affectedUsers)
+ ));
+ foreach ($affectedUsers as $username) {
+ $output->writeln(' - ' . $username);
+ }
+
+ if (!$input->getOption('force')) {
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion(
+ 'Reset passkey 2FA for all listed users? [y/N] ',
+ false
+ );
+ if (!$helper->ask($input, $output, $question)) {
+ $output->writeln('Aborted. ');
+ return Command::SUCCESS;
+ }
+ }
+
+ foreach (array_keys($affectedUsers) as $userId) {
+ foreach (self::PROVIDER_CODES as $providerCode) {
+ $config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
+ if (!empty($config) && isset($config['registration'])) {
+ $this->userConfigManager->resetProviderConfig($userId, $providerCode);
+ $resetCount++;
+ }
+ }
+ }
+
+ $output->writeln(sprintf(
+ 'Reset %d passkey configuration(s) for %d admin user(s). ',
+ $resetCount,
+ count($affectedUsers)
+ ));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/Controller/Account/Rename.php b/Controller/Account/Rename.php
index d183011..0a6f71d 100644
--- a/Controller/Account/Rename.php
+++ b/Controller/Account/Rename.php
@@ -57,6 +57,9 @@ public function execute(): Json
try {
$body = $this->json->unserialize($this->request->getContent());
+ if (!is_array($body)) {
+ $body = [];
+ }
$entityId = (int) ($body['entity_id'] ?? 0);
$friendlyName = (string) ($body['friendly_name'] ?? '');
$customerId = (int) $this->customerSession->getCustomerId();
diff --git a/Controller/Adminhtml/Passkey/Auth.php b/Controller/Adminhtml/Passkey/Auth.php
new file mode 100644
index 0000000..5e9e96d
--- /dev/null
+++ b/Controller/Adminhtml/Passkey/Auth.php
@@ -0,0 +1,60 @@
+getRequest()->getParam('provider', 'passkey');
+ $user = $this->session->getUser();
+ if ($user) {
+ $this->userConfigManager->setDefaultProvider(
+ (int) $user->getId(),
+ $providerCode
+ );
+ }
+
+ /** @var Page $page */
+ $page = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
+ $page->getConfig()->getTitle()->set(__('Passkey Authentication'));
+ return $page;
+ }
+
+ protected function _isAllowed(): bool
+ {
+ $user = $this->session->getUser();
+ if (!$user) {
+ return false;
+ }
+ $userId = (int) $user->getId();
+ $providerCode = $this->getRequest()->getParam('provider', 'passkey');
+
+ try {
+ $provider = $this->tfa->getProvider($providerCode);
+ return $provider !== null && $provider->isEnabled() && $provider->isActive($userId);
+ } catch (\Exception $e) {
+ return false;
+ }
+ }
+}
diff --git a/Controller/Adminhtml/Passkey/AuthPost.php b/Controller/Adminhtml/Passkey/AuthPost.php
new file mode 100644
index 0000000..4041d3e
--- /dev/null
+++ b/Controller/Adminhtml/Passkey/AuthPost.php
@@ -0,0 +1,88 @@
+jsonFactory->create();
+ $user = $this->session->getUser();
+ if ($user === null) {
+ return $result->setData([
+ 'success' => false,
+ 'message' => __('Session expired. Please sign in again.'),
+ ]);
+ }
+
+ try {
+ $providerCode = $this->getRequest()->getParam('provider', Engine::PROVIDER_CODE_ALL);
+ $credentialJson = $this->getRequest()->getParam('credential');
+
+ if ($credentialJson) {
+ // Phase 2: Verify assertion
+ $request = $this->dataObjectFactory->create(['data' => [
+ 'challenge_token' => $this->getRequest()->getParam('challenge_token'),
+ 'credential' => $credentialJson,
+ ]]);
+
+ $this->authenticate->verifyAssertion($user, $request);
+ $this->tfaSession->grantAccess();
+
+ return $result->setData([
+ 'success' => true,
+ 'redirect_url' => $this->getUrl('adminhtml/dashboard'),
+ ]);
+ }
+
+ // Phase 1: Get authentication options
+ $authData = $this->authenticate->getAuthenticationData($user, $providerCode);
+
+ return $result->setData($authData);
+ } catch (\Exception $e) {
+ $this->alert->event(
+ 'MageOS_PasskeyAuth',
+ 'Passkey authentication failed for admin user ' . $user->getUserName()
+ . ': ' . $e->getMessage(),
+ AlertInterface::LEVEL_WARNING
+ );
+
+ return $result->setData([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ protected function _isAllowed(): bool
+ {
+ $user = $this->session->getUser();
+ return $user !== null;
+ }
+}
diff --git a/Controller/Adminhtml/Passkey/Configure.php b/Controller/Adminhtml/Passkey/Configure.php
new file mode 100644
index 0000000..338b0fd
--- /dev/null
+++ b/Controller/Adminhtml/Passkey/Configure.php
@@ -0,0 +1,69 @@
+session = $session;
+ $this->tfa = $tfa;
+ }
+
+ public function execute(): Page
+ {
+ /** @var Page $page */
+ $page = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
+ $page->getConfig()->getTitle()->set(__('Passkey Configuration'));
+ return $page;
+ }
+
+ protected function _isAllowed()
+ {
+ if (!parent::_isAllowed()) {
+ return false;
+ }
+
+ $user = $this->session->getUser();
+ if (!$user) {
+ return false;
+ }
+ $userId = (int) $user->getId();
+ $providerCode = $this->getProviderCode();
+
+ try {
+ $provider = $this->tfa->getProvider($providerCode);
+ return $provider !== null && $provider->isEnabled() && !$provider->isActive($userId);
+ } catch (\Exception $e) {
+ return false;
+ }
+ }
+
+ private function getProviderCode(): string
+ {
+ $code = $this->getRequest()->getParam('provider');
+ if (in_array($code, ['passkey', 'passkey_hardware'], true)) {
+ return $code;
+ }
+ return 'passkey';
+ }
+}
diff --git a/Controller/Adminhtml/Passkey/ConfigurePost.php b/Controller/Adminhtml/Passkey/ConfigurePost.php
new file mode 100644
index 0000000..e23345b
--- /dev/null
+++ b/Controller/Adminhtml/Passkey/ConfigurePost.php
@@ -0,0 +1,104 @@
+session = $session;
+ }
+
+ public function execute(): ResultInterface
+ {
+ $result = $this->jsonFactory->create();
+ $user = $this->session->getUser();
+ if ($user === null) {
+ return $result->setData([
+ 'success' => false,
+ 'message' => __('Session expired. Please sign in again.'),
+ ]);
+ }
+
+ try {
+ $providerCode = $this->getRequest()->getParam('provider', Engine::PROVIDER_CODE_ALL);
+ $authenticatorPolicy = $providerCode === Engine::PROVIDER_CODE_HARDWARE ? 'hardware' : 'all';
+
+ $credentialJson = $this->getRequest()->getParam('credential');
+
+ if ($credentialJson) {
+ // Phase 2: Process attestation response
+ $challengeToken = $this->getRequest()->getParam('challenge_token');
+ $friendlyName = $this->getRequest()->getParam('friendly_name');
+
+ $this->configure->activate(
+ $user,
+ $challengeToken,
+ $credentialJson,
+ $providerCode,
+ $friendlyName
+ );
+
+ $this->tfaSession->grantAccess();
+ $this->alert->event(
+ 'MageOS_PasskeyAuth',
+ 'Passkey registered for admin user ' . $user->getUserName(),
+ AlertInterface::LEVEL_INFO
+ );
+
+ return $result->setData(['success' => true]);
+ }
+
+ // Phase 1: Generate registration options
+ $registrationData = $this->configure->getRegistrationData($user, $authenticatorPolicy);
+
+ return $result->setData($registrationData);
+ } catch (\Exception $e) {
+ $this->alert->event(
+ 'MageOS_PasskeyAuth',
+ 'Passkey registration failed for admin user ' . $user->getUserName()
+ . ': ' . $e->getMessage(),
+ AlertInterface::LEVEL_WARNING
+ );
+
+ return $result->setData([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ protected function _isAllowed()
+ {
+ if (!parent::_isAllowed()) {
+ return false;
+ }
+
+ $user = $this->session->getUser();
+ return $user !== null;
+ }
+}
diff --git a/Controller/Authentication/Options.php b/Controller/Authentication/Options.php
index 4c10074..65a9d8b 100644
--- a/Controller/Authentication/Options.php
+++ b/Controller/Authentication/Options.php
@@ -30,11 +30,18 @@ public function execute(): Json
try {
$body = $this->json->unserialize($this->request->getContent());
- $email = $body['email'] ?? null;
+ if (!is_array($body)) {
+ $body = [];
+ }
+ $email = isset($body['email']) && is_string($body['email']) ? $body['email'] : null;
$optionsJson = $this->authenticationOptions->generate($email);
+ $optionsData = json_decode($optionsJson, true);
+ if (!is_array($optionsData)) {
+ throw new LocalizedException(__('Unable to generate authentication options.'));
+ }
- return $resultJson->setData(json_decode($optionsJson, true));
+ return $resultJson->setData($optionsData);
} catch (LocalizedException $e) {
return $resultJson->setHttpResponseCode(400)->setData([
'errors' => true,
diff --git a/Controller/Authentication/Verify.php b/Controller/Authentication/Verify.php
index fcdb303..60ad7c8 100644
--- a/Controller/Authentication/Verify.php
+++ b/Controller/Authentication/Verify.php
@@ -40,13 +40,21 @@ public function execute(): Json
try {
$body = $this->json->unserialize($this->request->getContent());
+ if (!is_array($body)) {
+ $body = [];
+ }
$ip = $this->request->getClientIp() ?? 'unknown';
$this->rateLimiter->checkVerifyFailRate($ip);
+ $challengeToken = isset($body['challengeToken']) && is_string($body['challengeToken'])
+ ? $body['challengeToken']
+ : '';
+ $credential = $body['credential'] ?? [];
+
$result = $this->authenticationVerifier->verify(
- $body['challengeToken'] ?? '',
- $this->json->serialize($body['credential'] ?? [])
+ $challengeToken,
+ $this->json->serialize($credential)
);
$customer = $this->customerRepository->getById($result->getCustomerId());
diff --git a/Controller/Registration/Options.php b/Controller/Registration/Options.php
index 20ca27e..dc5bfa8 100644
--- a/Controller/Registration/Options.php
+++ b/Controller/Registration/Options.php
@@ -56,8 +56,12 @@ public function execute(): Json
try {
$customerId = (int) $this->customerSession->getCustomerId();
$optionsJson = $this->registrationOptions->generate($customerId);
+ $optionsData = json_decode($optionsJson, true);
+ if (!is_array($optionsData)) {
+ throw new LocalizedException(__('Unable to generate registration options.'));
+ }
- return $resultJson->setData(json_decode($optionsJson, true));
+ return $resultJson->setData($optionsData);
} catch (LocalizedException $e) {
return $resultJson->setHttpResponseCode(400)->setData([
'errors' => true,
diff --git a/Controller/Registration/Verify.php b/Controller/Registration/Verify.php
index 754def6..8ce3653 100644
--- a/Controller/Registration/Verify.php
+++ b/Controller/Registration/Verify.php
@@ -57,13 +57,24 @@ public function execute(): Json
try {
$body = $this->json->unserialize($this->request->getContent());
+ if (!is_array($body)) {
+ $body = [];
+ }
$customerId = (int) $this->customerSession->getCustomerId();
+ $challengeToken = isset($body['challengeToken']) && is_string($body['challengeToken'])
+ ? $body['challengeToken']
+ : '';
+ $credentialData = $body['credential'] ?? [];
+ $friendlyName = isset($body['friendlyName']) && is_string($body['friendlyName'])
+ ? $body['friendlyName']
+ : null;
+
$credential = $this->registrationVerifier->verify(
$customerId,
- $body['challengeToken'] ?? '',
- $this->json->serialize($body['credential'] ?? []),
- $body['friendlyName'] ?? null
+ $challengeToken,
+ $this->json->serialize($credentialData),
+ $friendlyName
);
return $resultJson->setData([
diff --git a/Model/AdminTfa/AdminTfaConfig.php b/Model/AdminTfa/AdminTfaConfig.php
new file mode 100644
index 0000000..cd03947
--- /dev/null
+++ b/Model/AdminTfa/AdminTfaConfig.php
@@ -0,0 +1,62 @@
+getAdminBaseUrl());
+ if (!isset($parsed['host'])) {
+ throw new LocalizedException(__('Could not determine admin domain from base URL.'));
+ }
+ return $parsed['host'];
+ }
+
+ public function getRpName(): string
+ {
+ return $this->storeManager->getStore(Store::ADMIN_CODE)->getName();
+ }
+
+ public function getAllowedOrigins(): array
+ {
+ $parsed = parse_url($this->getAdminBaseUrl());
+ $origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
+ if (isset($parsed['port'])) {
+ $origin .= ':' . $parsed['port'];
+ }
+ return [$origin];
+ }
+
+ public function getUserVerification(): string
+ {
+ return 'required';
+ }
+
+ public function getAuthenticatorAttachment(string $policy): ?string
+ {
+ return $policy === 'hardware' ? 'cross-platform' : null;
+ }
+
+ public function getAttestation(string $policy): string
+ {
+ return $policy === 'hardware' ? 'direct' : 'none';
+ }
+
+ private function getAdminBaseUrl(): string
+ {
+ return $this->storeManager->getStore(Store::ADMIN_CODE)->getBaseUrl();
+ }
+}
diff --git a/Model/AdminTfa/Authenticate.php b/Model/AdminTfa/Authenticate.php
new file mode 100644
index 0000000..276e153
--- /dev/null
+++ b/Model/AdminTfa/Authenticate.php
@@ -0,0 +1,166 @@
+getId();
+ $config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
+
+ if (empty($config) || !isset($config['registration']['credential_id'])) {
+ throw new LocalizedException(__('Passkey is not configured for this user.'));
+ }
+
+ $this->originValidator->validate($config['registration']);
+
+ $credentialId = base64_decode($config['registration']['credential_id']);
+
+ $allowCredentials = [
+ PublicKeyCredentialDescriptor::create('public-key', $credentialId),
+ ];
+
+ $options = PublicKeyCredentialRequestOptions::create(
+ challenge: random_bytes(32),
+ rpId: $this->adminTfaConfig->getRpId(),
+ allowCredentials: $allowCredentials,
+ userVerification: $this->adminTfaConfig->getUserVerification(),
+ timeout: 60000,
+ );
+
+ $serializer = $this->serializerFactory->get();
+ $optionsJson = $serializer->serialize($options, 'json');
+
+ $challengeToken = $this->challengeManager->create(self::CHALLENGE_TYPE, $optionsJson);
+
+ $optionsArray = json_decode($optionsJson, true);
+ if (!is_array($optionsArray)) {
+ throw new LocalizedException(__('Failed to decode authentication options.'));
+ }
+ $optionsArray['challengeToken'] = $challengeToken;
+
+ return $optionsArray;
+ }
+
+ public function verifyAssertion(UserInterface $user, DataObject $request): bool
+ {
+ $challengeToken = $request->getData('challenge_token');
+ $credentialJson = $request->getData('credential');
+
+ if (!$challengeToken || !$credentialJson) {
+ throw new LocalizedException(__('Missing challenge token or credential data.'));
+ }
+
+ $userId = (int) $user->getId();
+ $providerCode = $this->resolveProviderCode($userId);
+
+ $config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
+ if (empty($config) || !isset($config['registration'])) {
+ throw new LocalizedException(__('Passkey is not configured for this user.'));
+ }
+
+ $this->originValidator->validate($config['registration']);
+
+ $optionsJson = $this->challengeManager->consume($challengeToken, self::CHALLENGE_TYPE);
+
+ $serializer = $this->serializerFactory->get();
+
+ $requestOptions = $serializer->deserialize(
+ $optionsJson,
+ PublicKeyCredentialRequestOptions::class,
+ 'json'
+ );
+
+ $credential = $serializer->deserialize(
+ $credentialJson,
+ PublicKeyCredential::class,
+ 'json'
+ );
+
+ $response = $credential->response;
+ if (!$response instanceof \Webauthn\AuthenticatorAssertionResponse) {
+ throw new LocalizedException(__('Invalid assertion response.'));
+ }
+
+ $storedSource = $serializer->deserialize(
+ $config['registration']['credential_source'],
+ PublicKeyCredentialSource::class,
+ 'json'
+ );
+
+ $ceremonyStepManager = $this->ceremonyStepManagerProvider->getRequestCeremony();
+ $validator = AuthenticatorAssertionResponseValidator::create($ceremonyStepManager);
+
+ $updatedSource = $validator->check($storedSource, $response, $requestOptions);
+
+ // Clone detection: warn on counter regression
+ $storedCount = (int) ($config['registration']['sign_count'] ?? 0);
+ $newCount = $updatedSource->counter;
+ if ($newCount > 0 && $newCount <= $storedCount) {
+ $this->logger->warning('Passkey sign counter regression detected (possible clone)', [
+ 'admin_user_id' => $userId,
+ 'stored_count' => $storedCount,
+ 'new_count' => $newCount,
+ ]);
+ }
+
+ // Update stored credential
+ $config['registration']['credential_source'] = $serializer->serialize($updatedSource, 'json');
+ $config['registration']['sign_count'] = $updatedSource->counter;
+ $config['registration']['last_used_at'] = date('c');
+
+ $this->userConfigManager->setProviderConfig($userId, $providerCode, $config);
+
+ $this->logger->info('Admin passkey authentication successful', [
+ 'admin_user_id' => $userId,
+ 'provider' => $providerCode,
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Determine which passkey provider code is active for this user.
+ */
+ private function resolveProviderCode(int $userId): string
+ {
+ foreach ([Engine::PROVIDER_CODE_ALL, Engine::PROVIDER_CODE_HARDWARE] as $code) {
+ $config = $this->userConfigManager->getProviderConfig($userId, $code);
+ if (!empty($config) && isset($config['registration'])) {
+ return $code;
+ }
+ }
+ throw new LocalizedException(__('No passkey provider configured for this user.'));
+ }
+}
diff --git a/Model/AdminTfa/Configure.php b/Model/AdminTfa/Configure.php
new file mode 100644
index 0000000..4976842
--- /dev/null
+++ b/Model/AdminTfa/Configure.php
@@ -0,0 +1,164 @@
+adminTfaConfig->getRpName(),
+ $this->adminTfaConfig->getRpId()
+ );
+
+ $userEntity = PublicKeyCredentialUserEntity::create(
+ $user->getUserName(),
+ hash('sha256', (string) $user->getId()),
+ $user->getFirstName() . ' ' . $user->getLastName()
+ );
+
+ $challenge = random_bytes(32);
+
+ $credentialParameters = [
+ PublicKeyCredentialParameters::create('public-key', -7), // ES256
+ PublicKeyCredentialParameters::create('public-key', -257), // RS256
+ ];
+
+ $authenticatorSelection = AuthenticatorSelectionCriteria::create(
+ authenticatorAttachment: $this->adminTfaConfig->getAuthenticatorAttachment($authenticatorPolicy),
+ userVerification: $this->adminTfaConfig->getUserVerification(),
+ residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_DISCOURAGED,
+ );
+
+ $excludeCredentials = $this->getExcludeCredentials($user);
+
+ $options = PublicKeyCredentialCreationOptions::create(
+ rp: $rpEntity,
+ user: $userEntity,
+ challenge: $challenge,
+ pubKeyCredParams: $credentialParameters,
+ authenticatorSelection: $authenticatorSelection,
+ attestation: $this->adminTfaConfig->getAttestation($authenticatorPolicy),
+ excludeCredentials: $excludeCredentials,
+ timeout: 60000,
+ );
+
+ $serializer = $this->serializerFactory->get();
+ $optionsJson = $serializer->serialize($options, 'json');
+
+ $challengeToken = $this->challengeManager->create(self::CHALLENGE_TYPE, $optionsJson);
+
+ $optionsArray = json_decode($optionsJson, true);
+ if (!is_array($optionsArray)) {
+ throw new LocalizedException(__('Failed to decode registration options.'));
+ }
+ $optionsArray['challengeToken'] = $challengeToken;
+
+ return $optionsArray;
+ }
+
+ public function activate(
+ UserInterface $user,
+ string $challengeToken,
+ string $attestationResponseJson,
+ string $providerCode,
+ ?string $friendlyName = null
+ ): void {
+ $userId = (int) $user->getId();
+
+ $optionsJson = $this->challengeManager->consume($challengeToken, self::CHALLENGE_TYPE);
+
+ $serializer = $this->serializerFactory->get();
+
+ $creationOptions = $serializer->deserialize(
+ $optionsJson,
+ PublicKeyCredentialCreationOptions::class,
+ 'json'
+ );
+
+ $credential = $serializer->deserialize(
+ $attestationResponseJson,
+ PublicKeyCredential::class,
+ 'json'
+ );
+
+ $response = $credential->response;
+ if (!$response instanceof AuthenticatorAttestationResponse) {
+ throw new LocalizedException(__('Invalid attestation response.'));
+ }
+
+ $ceremonyStepManager = $this->ceremonyStepManagerProvider->getCreationCeremony();
+ $validator = AuthenticatorAttestationResponseValidator::create($ceremonyStepManager);
+
+ $credentialSource = $validator->check($response, $creationOptions);
+
+ $credentialSourceJson = $serializer->serialize($credentialSource, 'json');
+
+ $this->userConfigManager->setProviderConfig($userId, $providerCode, [
+ 'registration' => [
+ 'credential_source' => $credentialSourceJson,
+ 'credential_id' => base64_encode($credentialSource->publicKeyCredentialId),
+ 'rp_id' => $this->adminTfaConfig->getRpId(),
+ 'friendly_name' => $friendlyName ? mb_substr(trim($friendlyName), 0, 255) : null,
+ 'aaguid' => $credentialSource->aaguid->toString(),
+ 'registered_at' => date('c'),
+ 'last_used_at' => null,
+ 'sign_count' => $credentialSource->counter,
+ ],
+ ]);
+ $this->userConfigManager->activateProviderConfiguration($userId, $providerCode);
+
+ $this->logger->info('Admin passkey registered', [
+ 'admin_user_id' => $userId,
+ 'provider' => $providerCode,
+ 'aaguid' => $credentialSource->aaguid->toString(),
+ ]);
+ }
+
+ private function getExcludeCredentials(UserInterface $user): array
+ {
+ $excludeCredentials = [];
+ foreach ([Engine::PROVIDER_CODE_ALL, Engine::PROVIDER_CODE_HARDWARE] as $code) {
+ $config = $this->userConfigManager->getProviderConfig((int) $user->getId(), $code);
+ if (isset($config['registration']['credential_id'])) {
+ $excludeCredentials[] = PublicKeyCredentialDescriptor::create(
+ 'public-key',
+ base64_decode($config['registration']['credential_id'])
+ );
+ }
+ }
+ return $excludeCredentials;
+ }
+}
diff --git a/Model/AdminTfa/Engine.php b/Model/AdminTfa/Engine.php
new file mode 100644
index 0000000..16efb2d
--- /dev/null
+++ b/Model/AdminTfa/Engine.php
@@ -0,0 +1,63 @@
+getId();
+ $providerCode = $this->getProviderCode();
+
+ $config = $this->userConfigManager->getProviderConfig($userId, $providerCode);
+ if (empty($config) || !isset($config['registration'])) {
+ throw new LocalizedException(__(
+ 'Passkey is not configured for this user.'
+ ));
+ }
+
+ $this->originValidator->validate($config['registration']);
+
+ return $this->authenticate->verifyAssertion($user, $request);
+ }
+
+ public function getAuthenticatorPolicy(): string
+ {
+ return $this->authenticatorPolicy;
+ }
+
+ public function getProviderCode(): string
+ {
+ return $this->authenticatorPolicy === 'hardware'
+ ? self::PROVIDER_CODE_HARDWARE
+ : self::PROVIDER_CODE_ALL;
+ }
+}
diff --git a/Model/AdminTfa/OriginValidator.php b/Model/AdminTfa/OriginValidator.php
new file mode 100644
index 0000000..4695f79
--- /dev/null
+++ b/Model/AdminTfa/OriginValidator.php
@@ -0,0 +1,34 @@
+adminTfaConfig->getRpId();
+ if ($storedRpId !== $currentRpId) {
+ throw new LocalizedException(__(
+ 'The admin domain has changed since your passkey was registered '
+ . '(was "%1", now "%2"). Please ask an administrator to reset your '
+ . 'passkey configuration.',
+ $storedRpId,
+ $currentRpId
+ ));
+ }
+ }
+}
diff --git a/Model/Authentication/OptionsGenerator.php b/Model/Authentication/OptionsGenerator.php
index a691a3e..b95b4d2 100644
--- a/Model/Authentication/OptionsGenerator.php
+++ b/Model/Authentication/OptionsGenerator.php
@@ -86,6 +86,9 @@ public function generate(?string $email = null): string
);
$optionsArray = $this->json->unserialize($serializedOptions);
+ if (!is_array($optionsArray)) {
+ throw new LocalizedException(__('Failed to decode authentication options.'));
+ }
$optionsArray['challengeToken'] = $challengeToken;
return $this->json->serialize($optionsArray);
diff --git a/Model/Config.php b/Model/Config.php
index 4a80e18..5900d10 100644
--- a/Model/Config.php
+++ b/Model/Config.php
@@ -4,10 +4,11 @@
namespace MageOS\PasskeyAuth\Model;
+use MageOS\PasskeyAuth\Api\WebAuthnConfigInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\StoreManagerInterface;
-class Config
+class Config implements WebAuthnConfigInterface
{
public const XML_PATH_ENABLED = 'customer/passkey/enabled';
public const XML_PATH_PROMPT_AFTER_LOGIN = 'customer/passkey/prompt_after_login';
diff --git a/Model/Registration/OptionsGenerator.php b/Model/Registration/OptionsGenerator.php
index ef064c8..36d071a 100644
--- a/Model/Registration/OptionsGenerator.php
+++ b/Model/Registration/OptionsGenerator.php
@@ -105,6 +105,9 @@ public function generate(int $customerId): string
);
$optionsArray = $this->json->unserialize($serializedOptions);
+ if (!is_array($optionsArray)) {
+ throw new LocalizedException(__('Failed to decode registration options.'));
+ }
$optionsArray['challengeToken'] = $challengeToken;
return $this->json->serialize($optionsArray);
diff --git a/Model/WebAuthn/CeremonyStepManagerProvider.php b/Model/WebAuthn/CeremonyStepManagerProvider.php
index 0890b0b..aadbb0c 100644
--- a/Model/WebAuthn/CeremonyStepManagerProvider.php
+++ b/Model/WebAuthn/CeremonyStepManagerProvider.php
@@ -4,7 +4,7 @@
namespace MageOS\PasskeyAuth\Model\WebAuthn;
-use MageOS\PasskeyAuth\Model\Config;
+use MageOS\PasskeyAuth\Api\WebAuthnConfigInterface;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\CeremonyStep\CeremonyStepManager;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
@@ -12,7 +12,7 @@
class CeremonyStepManagerProvider
{
public function __construct(
- private readonly Config $config,
+ private readonly WebAuthnConfigInterface $config,
private readonly AttestationStatementSupportManager $attestationStatementSupportManager
) {
}
diff --git a/Test/Unit/Console/Command/ResetPasskeyTfaCommandTest.php b/Test/Unit/Console/Command/ResetPasskeyTfaCommandTest.php
new file mode 100644
index 0000000..e282328
--- /dev/null
+++ b/Test/Unit/Console/Command/ResetPasskeyTfaCommandTest.php
@@ -0,0 +1,106 @@
+userConfigManager = $this->createMock(UserConfigManagerInterface::class);
+ $this->userCollectionFactory = $this->createMock(UserCollectionFactory::class);
+
+ $this->command = new ResetPasskeyTfaCommand(
+ $this->userConfigManager,
+ $this->userCollectionFactory
+ );
+ }
+
+ public function testCommandHasCorrectName(): void
+ {
+ $this->assertSame('security:tfa:passkey:reset-all', $this->command->getName());
+ }
+
+ public function testForceOptionSkipsConfirmation(): void
+ {
+ $definition = $this->command->getDefinition();
+ $this->assertTrue($definition->hasOption('force'));
+ }
+
+ public function testExecuteResetsMatchingUsers(): void
+ {
+ $user1 = $this->createMock(User::class);
+ $user1->method('getId')->willReturn(1);
+ $user1->method('getUserName')->willReturn('admin1');
+
+ $user2 = $this->createMock(User::class);
+ $user2->method('getId')->willReturn(2);
+ $user2->method('getUserName')->willReturn('admin2');
+
+ $collection = $this->createMock(UserCollection::class);
+ $collection->method('getIterator')->willReturn(new \ArrayIterator([$user1, $user2]));
+ $this->userCollectionFactory->method('create')->willReturn($collection);
+
+ $this->userConfigManager->method('getProviderConfig')
+ ->willReturnCallback(function (int $userId, string $code) {
+ if ($userId === 1 && $code === Engine::PROVIDER_CODE_ALL) {
+ return ['registration' => ['credential_id' => 'abc']];
+ }
+ if ($userId === 2 && $code === Engine::PROVIDER_CODE_HARDWARE) {
+ return ['registration' => ['credential_id' => 'def']];
+ }
+ return null;
+ });
+
+ $this->userConfigManager->expects($this->exactly(2))
+ ->method('resetProviderConfig');
+
+ $input = $this->createMock(InputInterface::class);
+ $input->method('getOption')->with('force')->willReturn(true);
+
+ $output = $this->createMock(OutputInterface::class);
+
+ $ref = new \ReflectionMethod($this->command, 'execute');
+ $ref->setAccessible(true);
+ $result = $ref->invoke($this->command, $input, $output);
+
+ $this->assertSame(0, $result);
+ }
+
+ public function testExecuteReturnsSuccessWhenNoUsersConfigured(): void
+ {
+ $collection = $this->createMock(UserCollection::class);
+ $collection->method('getIterator')->willReturn(new \ArrayIterator([]));
+ $this->userCollectionFactory->method('create')->willReturn($collection);
+
+ $this->userConfigManager->expects($this->never())->method('resetProviderConfig');
+
+ $input = $this->createMock(InputInterface::class);
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with('No admin users have passkey 2FA configured. ');
+
+ $ref = new \ReflectionMethod($this->command, 'execute');
+ $ref->setAccessible(true);
+ $result = $ref->invoke($this->command, $input, $output);
+
+ $this->assertSame(0, $result);
+ }
+}
diff --git a/Test/Unit/Model/AdminTfa/AdminTfaConfigTest.php b/Test/Unit/Model/AdminTfa/AdminTfaConfigTest.php
new file mode 100644
index 0000000..f19e7b6
--- /dev/null
+++ b/Test/Unit/Model/AdminTfa/AdminTfaConfigTest.php
@@ -0,0 +1,103 @@
+storeManager = $this->createMock(StoreManagerInterface::class);
+ $this->config = new AdminTfaConfig($this->storeManager);
+ }
+
+ public function testGetRpIdExtractsDomainFromAdminBaseUrl(): void
+ {
+ $store = $this->createMock(Store::class);
+ $store->method('getBaseUrl')->willReturn('https://admin.example.com/');
+ $this->storeManager->method('getStore')
+ ->with(Store::ADMIN_CODE)
+ ->willReturn($store);
+
+ $this->assertSame('admin.example.com', $this->config->getRpId());
+ }
+
+ public function testGetRpIdHandlesPortInUrl(): void
+ {
+ $store = $this->createMock(Store::class);
+ $store->method('getBaseUrl')->willReturn('https://admin.example.com:8443/backend/');
+ $this->storeManager->method('getStore')
+ ->with(Store::ADMIN_CODE)
+ ->willReturn($store);
+
+ $this->assertSame('admin.example.com', $this->config->getRpId());
+ }
+
+ public function testGetAllowedOriginsReturnsSchemeAndHost(): void
+ {
+ $store = $this->createMock(Store::class);
+ $store->method('getBaseUrl')->willReturn('https://admin.example.com/');
+ $this->storeManager->method('getStore')
+ ->with(Store::ADMIN_CODE)
+ ->willReturn($store);
+
+ $this->assertSame(['https://admin.example.com'], $this->config->getAllowedOrigins());
+ }
+
+ public function testGetAllowedOriginsIncludesNonStandardPort(): void
+ {
+ $store = $this->createMock(Store::class);
+ $store->method('getBaseUrl')->willReturn('https://admin.example.com:8443/backend/');
+ $this->storeManager->method('getStore')
+ ->with(Store::ADMIN_CODE)
+ ->willReturn($store);
+
+ $this->assertSame(['https://admin.example.com:8443'], $this->config->getAllowedOrigins());
+ }
+
+ public function testGetRpNameReturnsStoreName(): void
+ {
+ $store = $this->createMock(Store::class);
+ $store->method('getName')->willReturn('My Store Admin');
+ $this->storeManager->method('getStore')
+ ->with(Store::ADMIN_CODE)
+ ->willReturn($store);
+
+ $this->assertSame('My Store Admin', $this->config->getRpName());
+ }
+
+ public function testGetUserVerificationReturnsRequired(): void
+ {
+ $this->assertSame('required', $this->config->getUserVerification());
+ }
+
+ public function testGetAuthenticatorAttachmentReturnsNullForAllPolicy(): void
+ {
+ $this->assertNull($this->config->getAuthenticatorAttachment('all'));
+ }
+
+ public function testGetAuthenticatorAttachmentReturnsCrossPlatformForHardwarePolicy(): void
+ {
+ $this->assertSame('cross-platform', $this->config->getAuthenticatorAttachment('hardware'));
+ }
+
+ public function testGetAttestationReturnsNoneForAllPolicy(): void
+ {
+ $this->assertSame('none', $this->config->getAttestation('all'));
+ }
+
+ public function testGetAttestationReturnsDirectForHardwarePolicy(): void
+ {
+ $this->assertSame('direct', $this->config->getAttestation('hardware'));
+ }
+}
diff --git a/Test/Unit/Model/AdminTfa/EngineTest.php b/Test/Unit/Model/AdminTfa/EngineTest.php
new file mode 100644
index 0000000..176ab35
--- /dev/null
+++ b/Test/Unit/Model/AdminTfa/EngineTest.php
@@ -0,0 +1,99 @@
+userConfigManager = $this->createMock(UserConfigManagerInterface::class);
+ $this->originValidator = $this->createMock(OriginValidator::class);
+ $this->authenticate = $this->createMock(AuthenticateInterface::class);
+
+ $this->engine = new Engine(
+ $this->userConfigManager,
+ $this->originValidator,
+ $this->authenticate,
+ 'all'
+ );
+ }
+
+ public function testIsEnabledReturnsTrue(): void
+ {
+ $this->assertTrue($this->engine->isEnabled());
+ }
+
+ public function testVerifyDelegatesToAuthenticate(): void
+ {
+ $user = $this->createMock(UserInterface::class);
+ $user->method('getId')->willReturn('42');
+ $request = new DataObject(['credential' => '{"id":"abc"}', 'challenge_token' => 'tok123']);
+
+ $this->userConfigManager->method('getProviderConfig')
+ ->with(42, 'passkey')
+ ->willReturn(['registration' => ['rp_id' => 'admin.example.com']]);
+
+ $this->originValidator->expects($this->once())
+ ->method('validate')
+ ->with(['rp_id' => 'admin.example.com']);
+
+ $this->authenticate->expects($this->once())
+ ->method('verifyAssertion')
+ ->with($user, $request)
+ ->willReturn(true);
+
+ $this->assertTrue($this->engine->verify($user, $request));
+ }
+
+ public function testVerifyThrowsOnDomainMismatch(): void
+ {
+ $user = $this->createMock(UserInterface::class);
+ $user->method('getId')->willReturn('42');
+ $request = new DataObject();
+
+ $this->userConfigManager->method('getProviderConfig')
+ ->willReturn(['registration' => ['rp_id' => 'old.example.com']]);
+
+ $this->originValidator->method('validate')
+ ->willThrowException(new LocalizedException(__('domain has changed')));
+
+ $this->expectException(LocalizedException::class);
+ $this->engine->verify($user, $request);
+ }
+
+ public function testVerifyThrowsWhenNotConfigured(): void
+ {
+ $user = $this->createMock(UserInterface::class);
+ $user->method('getId')->willReturn('42');
+ $request = new DataObject();
+
+ $this->userConfigManager->method('getProviderConfig')
+ ->willReturn(null);
+
+ $this->expectException(LocalizedException::class);
+ $this->expectExceptionMessage('not configured');
+ $this->engine->verify($user, $request);
+ }
+
+ public function testGetAuthenticatorPolicyReturnsConstructorValue(): void
+ {
+ $this->assertSame('all', $this->engine->getAuthenticatorPolicy());
+ }
+}
diff --git a/Test/Unit/Model/AdminTfa/OriginValidatorTest.php b/Test/Unit/Model/AdminTfa/OriginValidatorTest.php
new file mode 100644
index 0000000..d8612e5
--- /dev/null
+++ b/Test/Unit/Model/AdminTfa/OriginValidatorTest.php
@@ -0,0 +1,48 @@
+adminTfaConfig = $this->createMock(AdminTfaConfig::class);
+ $this->validator = new OriginValidator($this->adminTfaConfig);
+ }
+
+ public function testValidatePassesWhenRpIdMatches(): void
+ {
+ $this->adminTfaConfig->method('getRpId')->willReturn('admin.example.com');
+ $config = ['rp_id' => 'admin.example.com'];
+ $this->validator->validate($config);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testValidateThrowsWhenRpIdMismatches(): void
+ {
+ $this->adminTfaConfig->method('getRpId')->willReturn('new-admin.example.com');
+ $config = ['rp_id' => 'old-admin.example.com'];
+ $this->expectException(LocalizedException::class);
+ $this->expectExceptionMessage('admin domain has changed');
+ $this->validator->validate($config);
+ }
+
+ public function testValidatePassesWhenNoRpIdStored(): void
+ {
+ $this->adminTfaConfig->method('getRpId')->willReturn('admin.example.com');
+ $config = [];
+ $this->validator->validate($config);
+ $this->addToAssertionCount(1);
+ }
+}
diff --git a/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php b/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php
index 74a4211..b6dedb3 100644
--- a/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php
+++ b/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php
@@ -4,21 +4,19 @@
namespace MageOS\PasskeyAuth\Test\Unit\Model\WebAuthn;
+use MageOS\PasskeyAuth\Api\WebAuthnConfigInterface;
use MageOS\PasskeyAuth\Model\WebAuthn\CeremonyStepManagerProvider;
-use MageOS\PasskeyAuth\Test\Unit\Traits\MocksConfigTrait;
use PHPUnit\Framework\TestCase;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\CeremonyStep\CeremonyStepManager;
class CeremonyStepManagerProviderTest extends TestCase
{
- use MocksConfigTrait;
-
private CeremonyStepManagerProvider $provider;
protected function setUp(): void
{
- $configMock = $this->createConfigMock();
+ $configMock = $this->createMock(WebAuthnConfigInterface::class);
$configMock->method('getAllowedOrigins')->willReturn(['https://example.com']);
$this->provider = new CeremonyStepManagerProvider(
diff --git a/composer.json b/composer.json
index e9eeb1d..7811cd2 100644
--- a/composer.json
+++ b/composer.json
@@ -9,6 +9,7 @@
"magento/module-customer": "*",
"magento/module-integration": "*",
"magento/module-store": "*",
+ "magento/module-two-factor-auth": "*",
"web-auth/webauthn-lib": "^5.0"
},
"require-dev": {
diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml
new file mode 100644
index 0000000..fe81f9c
--- /dev/null
+++ b/etc/adminhtml/routes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/etc/di.xml b/etc/di.xml
index 037d251..34c36e8 100644
--- a/etc/di.xml
+++ b/etc/di.xml
@@ -2,6 +2,10 @@
+
+
+
@@ -27,4 +31,93 @@
+
+
+
+
+
+
+
+
+ MageOS\PasskeyAuth\Model\AdminTfa\AdminTfaConfig
+
+
+
+
+
+
+ MageOS\PasskeyAuth\Model\WebAuthn\AdminCeremonyStepManagerProvider
+
+
+
+
+
+
+ MageOS\PasskeyAuth\Model\WebAuthn\AdminCeremonyStepManagerProvider
+
+
+
+
+
+
+ all
+
+
+
+
+
+ hardware
+
+
+
+
+
+
+ MageOS\PasskeyAuth\Model\AdminTfa\Engine\AllPasskeys
+ passkey
+ Passkey
+ MageOS_PasskeyAuth::images/providers/passkey.png
+ tfa/passkey/configure
+ tfa/passkey/auth
+ true
+
+
+
+
+
+ MageOS\PasskeyAuth\Model\AdminTfa\Engine\HardwareOnly
+ passkey_hardware
+ Passkey (Hardware Key Only)
+ MageOS_PasskeyAuth::images/providers/passkey-hardware.png
+ tfa/passkey/configure
+ tfa/passkey/auth
+ true
+
+
+
+
+
+
+
+ - MageOS\PasskeyAuth\Model\Provider\Passkey
+ - MageOS\PasskeyAuth\Model\Provider\PasskeyHardware
+
+
+
+
+
+
+
+
+ - MageOS\PasskeyAuth\Console\Command\ResetPasskeyTfaCommand
+
+
+
diff --git a/etc/module.xml b/etc/module.xml
index 62e9bd9..7f370c4 100644
--- a/etc/module.xml
+++ b/etc/module.xml
@@ -6,6 +6,7 @@
+
diff --git a/view/adminhtml/layout/tfa_passkey_auth.xml b/view/adminhtml/layout/tfa_passkey_auth.xml
new file mode 100644
index 0000000..47dc2f5
--- /dev/null
+++ b/view/adminhtml/layout/tfa_passkey_auth.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+ -
+
-
+
- MageOS_PasskeyAuth/js/passkey-tfa-auth
+
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/layout/tfa_passkey_configure.xml b/view/adminhtml/layout/tfa_passkey_configure.xml
new file mode 100644
index 0000000..755b876
--- /dev/null
+++ b/view/adminhtml/layout/tfa_passkey_configure.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+ -
+
-
+
- MageOS_PasskeyAuth/js/passkey-tfa-configure
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/templates/tfa/passkey/auth.phtml b/view/adminhtml/templates/tfa/passkey/auth.phtml
new file mode 100644
index 0000000..c0773d3
--- /dev/null
+++ b/view/adminhtml/templates/tfa/passkey/auth.phtml
@@ -0,0 +1,27 @@
+getRequest()->getParam('provider', 'passkey');
+?>
+
+
+
+= $block->getChildHtml('tfa-change-provider') ?>
+
diff --git a/view/adminhtml/templates/tfa/passkey/configure.phtml b/view/adminhtml/templates/tfa/passkey/configure.phtml
new file mode 100644
index 0000000..d39c2ee
--- /dev/null
+++ b/view/adminhtml/templates/tfa/passkey/configure.phtml
@@ -0,0 +1,26 @@
+getRequest()->getParam('provider', 'passkey');
+?>
+
+
+
+
diff --git a/view/adminhtml/web/images/providers/passkey-hardware.png b/view/adminhtml/web/images/providers/passkey-hardware.png
new file mode 100644
index 0000000..d984977
Binary files /dev/null and b/view/adminhtml/web/images/providers/passkey-hardware.png differ
diff --git a/view/adminhtml/web/images/providers/passkey.png b/view/adminhtml/web/images/providers/passkey.png
new file mode 100644
index 0000000..d984977
Binary files /dev/null and b/view/adminhtml/web/images/providers/passkey.png differ
diff --git a/view/adminhtml/web/js/passkey-tfa-auth.js b/view/adminhtml/web/js/passkey-tfa-auth.js
new file mode 100644
index 0000000..c8e837d
--- /dev/null
+++ b/view/adminhtml/web/js/passkey-tfa-auth.js
@@ -0,0 +1,102 @@
+define([
+ 'uiComponent',
+ 'jquery',
+ 'MageOS_PasskeyAuth/js/passkey-core',
+ 'mage/translate'
+], function (Component, $, passkeyCore, $t) {
+ return Component.extend({
+ defaults: {
+ template: 'MageOS_PasskeyAuth/tfa/passkey/auth',
+ postUrl: '',
+ successUrl: '',
+ provider: 'passkey',
+ currentStep: 'idle',
+ errorMessage: ''
+ },
+
+ initObservable: function () {
+ this._super().observe(['currentStep', 'errorMessage']);
+ return this;
+ },
+
+ initialize: function () {
+ this._super();
+
+ if (!passkeyCore.isAvailable()) {
+ this.currentStep('no-webauthn');
+ return this;
+ }
+
+ // Auto-trigger authentication on load
+ this.authenticate();
+
+ return this;
+ },
+
+ authenticate: function () {
+ var self = this;
+ this.currentStep('authenticating');
+ this.errorMessage('');
+
+ $.ajax({
+ url: this.postUrl,
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ form_key: window.FORM_KEY,
+ provider: this.provider
+ }
+ }).done(function (options) {
+ if (options.message) {
+ self.errorMessage(options.message);
+ self.currentStep('error');
+ return;
+ }
+
+ var challengeToken = options.challengeToken;
+ var requestOptions = passkeyCore.prepareRequestOptions(options);
+
+ navigator.credentials.get({ publicKey: requestOptions })
+ .then(function (credential) {
+ var serialized = passkeyCore.serializeAssertionResponse(credential);
+
+ return $.ajax({
+ url: self.postUrl,
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ form_key: window.FORM_KEY,
+ challenge_token: challengeToken,
+ credential: JSON.stringify(serialized),
+ provider: self.provider
+ }
+ });
+ })
+ .then(function (response) {
+ if (response.success) {
+ self.currentStep('success');
+ window.location.href = response.redirect_url || self.successUrl;
+ } else {
+ self.errorMessage(response.message || $t('Authentication failed.'));
+ self.currentStep('error');
+ }
+ })
+ .catch(function (err) {
+ if (err.name !== 'AbortError') {
+ self.errorMessage(err.message || $t('Authentication failed.'));
+ self.currentStep('error');
+ } else {
+ self.currentStep('idle');
+ }
+ });
+ }).fail(function () {
+ self.errorMessage($t('Server error. Please try again.'));
+ self.currentStep('error');
+ });
+ },
+
+ retry: function () {
+ this.authenticate();
+ }
+ });
+});
diff --git a/view/adminhtml/web/js/passkey-tfa-configure.js b/view/adminhtml/web/js/passkey-tfa-configure.js
new file mode 100644
index 0000000..988a7b9
--- /dev/null
+++ b/view/adminhtml/web/js/passkey-tfa-configure.js
@@ -0,0 +1,102 @@
+define([
+ 'uiComponent',
+ 'jquery',
+ 'MageOS_PasskeyAuth/js/passkey-core',
+ 'mage/translate'
+], function (Component, $, passkeyCore, $t) {
+ return Component.extend({
+ defaults: {
+ template: 'MageOS_PasskeyAuth/tfa/passkey/configure',
+ postUrl: '',
+ successUrl: '',
+ provider: 'passkey',
+ currentStep: 'idle',
+ errorMessage: '',
+ friendlyName: ''
+ },
+
+ initObservable: function () {
+ this._super().observe(['currentStep', 'errorMessage', 'friendlyName']);
+ return this;
+ },
+
+ initialize: function () {
+ this._super();
+
+ if (!passkeyCore.isAvailable()) {
+ this.currentStep('no-webauthn');
+ }
+
+ return this;
+ },
+
+ register: function () {
+ var self = this;
+ this.currentStep('registering');
+ this.errorMessage('');
+
+ $.ajax({
+ url: this.postUrl,
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ form_key: window.FORM_KEY
+ }
+ }).done(function (options) {
+ if (options.message) {
+ self.errorMessage(options.message);
+ self.currentStep('error');
+ return;
+ }
+
+ var challengeToken = options.challengeToken;
+ var createOptions = passkeyCore.prepareCreationOptions(options);
+
+ navigator.credentials.create({ publicKey: createOptions })
+ .then(function (credential) {
+ var serialized = passkeyCore.serializeAttestationResponse(credential);
+
+ return $.ajax({
+ url: self.postUrl,
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ form_key: window.FORM_KEY,
+ challenge_token: challengeToken,
+ credential: JSON.stringify(serialized),
+ friendly_name: self.friendlyName(),
+ provider: self.provider
+ }
+ });
+ })
+ .then(function (response) {
+ if (response.success) {
+ self.currentStep('registered');
+ setTimeout(function () {
+ window.location.href = self.successUrl;
+ }, 1500);
+ } else {
+ self.errorMessage(response.message || $t('Registration failed.'));
+ self.currentStep('error');
+ }
+ })
+ .catch(function (err) {
+ if (err.name !== 'AbortError') {
+ self.errorMessage(err.message || $t('Registration failed.'));
+ self.currentStep('error');
+ } else {
+ self.currentStep('idle');
+ }
+ });
+ }).fail(function () {
+ self.errorMessage($t('Server error. Please try again.'));
+ self.currentStep('error');
+ });
+ },
+
+ retry: function () {
+ this.currentStep('idle');
+ this.errorMessage('');
+ }
+ });
+});
diff --git a/view/adminhtml/web/template/tfa/passkey/auth.html b/view/adminhtml/web/template/tfa/passkey/auth.html
new file mode 100644
index 0000000..2802070
--- /dev/null
+++ b/view/adminhtml/web/template/tfa/passkey/auth.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/web/template/tfa/passkey/configure.html b/view/adminhtml/web/template/tfa/passkey/configure.html
new file mode 100644
index 0000000..60e3961
--- /dev/null
+++ b/view/adminhtml/web/template/tfa/passkey/configure.html
@@ -0,0 +1,58 @@
+
diff --git a/view/frontend/web/js/passkey-core.js b/view/base/web/js/passkey-core.js
similarity index 100%
rename from view/frontend/web/js/passkey-core.js
rename to view/base/web/js/passkey-core.js