Skip to content

Passkey (Webauthn) login ignores menu-item-based login redirect and falls back to home page #48308

Description

@digitalspyders

Summary

When a Joomla login menu item is configured with a Menu Item login redirect (the default redirect type), logging in via the System - Passkey (Passwordless) Login plugin (plg_system_webauthn) redirects the user to the site home page instead of the configured target page.

Username/password login on the same form works correctly and redirects to the configured page.

There is also a secondary issue: even when the redirect target is reached, the Webauthn plugin redirects to the raw non-SEF URL (e.g. index.php?Itemid=333) instead of the SEF equivalent (e.g. /student-home).

Steps to reproduce

  1. Create a Login Form menu item (Users → Login Form).
  2. In the Login Form menu item options, set Login RedirectMenu Item → select any target menu item (e.g. a Page Builder page).
  3. Enable the System - Webauthn (Passkeys) plugin.
  4. Set up a passkey for a user account.
  5. Log out and navigate to the login page.
  6. Log in using the Passkey button.

Expected result

User is redirected to the selected menu item's SEF URL (e.g. https://example.com/student-home).

Actual result

User is redirected to the site home page (Uri::base()).

If the redirect is manually worked around (see below), the user reaches the correct page but via the raw non-SEF URL (https://example.com/index.php?Itemid=333) instead of the SEF URL.

System information

  • Joomla: 6.1.3 Stable
  • PHP: 8.x
  • Plugin: System - Webauthn (Passkeys) — core plugin
  • Login redirect type: Menu Item (stores a bare integer Itemid in the return field)

Root cause

Two bugs in plg_system_webauthn, both in the PluginTraits files:

Bug 1: AjaxHandlerChallenge does not handle numeric menu item IDs

When the login redirect is configured as a "Menu Item", Joomla stores the target menu item's ID as a bare integer in the login form's hidden return field (base64-encoded). For example, if the target menu item is ID 333, the return field contains base64_encode("333").

The core com_users UserController::login() explicitly handles this case (components/com_users/src/Controller/UserController.php, lines 54-64):

if (is_numeric($data['return'])) {
    $itemId         = (int) $data['return'];
    $data['return'] = 'index.php?Itemid=' . $itemId;

    if (Multilanguage::isEnabled()) {
        $language = $this->getModel('Login', 'Site')->getMenuLanguage($itemId);

        if ($language !== '*') {
            $data['return'] .= '&lang=' . $language;
        }
    }
} elseif (!Uri::isInternal($data['return'])) {
    $data['return'] = '';
}

The Webauthn challenge handler (plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php) decodes the returnUrl but skips the is_numeric() check and passes the value directly to Uri::isInternal(). Since Uri::isInternal("333") returns false (a bare integer has no host and doesn't start with index.php), the handler falls back to Uri::base() — the home page.

Bug 2: AjaxHandlerLogin does not route the redirect URL through Joomla's router

Even after fixing Bug 1, the login handler redirects with the raw internal URL:

$this->getApplication()->redirect($returnUrl);

The core UserController::login() routes the URL through Route::_() before redirecting (line 108):

$this->app->redirect(Route::_($this->app->getUserState('users.login.form.return'), false));

The Webauthn login handler does not, so the user sees index.php?Itemid=333 instead of the SEF URL.

Suggested fix

File 1: plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php

Add the is_numeric()index.php?Itemid=X conversion (mirroring UserController::login()), including multilanguage support.

Diff:

--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerChallenge.php
@@ -12,10 +12,13 @@
 
 use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxChallenge;
 use Joomla\CMS\Factory;
+use Joomla\CMS\Language\Multilanguage;
 use Joomla\CMS\Uri\Uri;
 use Joomla\CMS\User\User;
 use Joomla\CMS\User\UserFactoryInterface;
 use Joomla\CMS\User\UserHelper;
+use Joomla\Database\DatabaseInterface;
+use Joomla\Database\ParameterType;
 
 // phpcs:disable PSR1.Files.SideEffects
 \defined('_JEXEC') or die;
@@ -59,8 +62,36 @@
         $returnUrl = $input->getBase64('returnUrl', $returnUrl);
         $returnUrl = base64_decode($returnUrl);
 
-        // For security reasons the post-login redirection URL must be internal to the site.
-        if (!Uri::isInternal($returnUrl)) {
+        // Handle numeric menu item IDs (same as com_users UserController::login()).
+        // When the login redirect is configured as a "Menu Item" the stored value
+        // is a bare integer (the Itemid) which Uri::isInternal() rejects, causing
+        // the post-login redirect to fall back to the site root.
+        if (is_numeric($returnUrl)) {
+            $itemId    = (int) $returnUrl;
+            $returnUrl = 'index.php?Itemid=' . $itemId;
+
+            if (Multilanguage::isEnabled()) {
+                $db    = Factory::getContainer()->get(DatabaseInterface::class);
+                $query = $db->createQuery()
+                    ->select($db->quoteName('language'))
+                    ->from($db->quoteName('#__menu'))
+                    ->where($db->quoteName('client_id') . ' = 0')
+                    ->where($db->quoteName('id') . ' = :id')
+                    ->bind(':id', $itemId, ParameterType::INTEGER);
+                $db->setQuery($query);
+
+                try {
+                    $language = $db->loadResult();
+                } catch (\Exception) {
+                    $language = '';
+                }
+
+                if ($language !== '*' && !empty($language)) {
+                    $returnUrl .= '&lang=' . $language;
+                }
+            }
+        } elseif (!Uri::isInternal($returnUrl)) {
+            // For security reasons the post-login redirection URL must be internal to the site.
             // If the URL wasn't internal redirect to the site's root.
             $returnUrl = Uri::base();
         }

File 2: plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php

Wrap the redirect URL with Route::_() to produce SEF URLs, matching the core UserController::login() behavior.

Diff:

--- a/plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
+++ b/plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
@@ -17,6 +17,7 @@
 use Joomla\CMS\Language\Text;
 use Joomla\CMS\Log\Log;
 use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Router\Route;
 use Joomla\CMS\Uri\Uri;
 use Joomla\CMS\User\User;
 use Joomla\CMS\User\UserFactoryInterface;
@@ -129,7 +130,7 @@
             $session->set('plg_system_webauthn.userId', null);
 
             // Redirect back to the page we were before.
-            $this->getApplication()->redirect($returnUrl);
+            $this->getApplication()->redirect(Route::_($returnUrl, false));
         }
     }

Route::_() is safe for all returnUrl values because it returns the URL unchanged if it doesn't start with index.php (see libraries/src/Router/Route.php, Route::link() early return). This covers the Uri::base() fallback and any full-URL Uri::current() value.

Additional notes

  • This affects all Joomla versions shipping the current plg_system_webauthn (confirmed on 6.1.3; the code pattern has been unchanged since 4.x).
  • The bug only manifests when the login redirect is configured as Menu Item (the default). The Internal URL redirect type works around Bug 1 but still hits Bug 2 (non-SEF redirect).
  • The fix mirrors the existing, proven logic in com_users UserController::login() — no new patterns introduced.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions