[plasma/kscreenlocker] greeter: pam: flexible authenticator support

Harald Sitter <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit a5ed9ca05964b6e394fc172014fedf5f4dda509a by Harald Sitter.
Committed on 18/08/2026 at 11:14.
Pushed by sitter into branch 'master'.

pam: flexible authenticator support

rework the internals of the pam support to facilitate more flexible
backends. this should be backwards compatible for the time being as only
internals change.

it's largely straight forward. instead of having an interactive /
noninteractive split we now have an active / fingerprint split (for the
purposes of public PamAuthenticators API they map 1:1).
both new types are mutable which allows us to switch between different
active configurations (pam, face, u2f, smartcard ...) or disable the
fingerprint entirely.

the way this works from a UX perspective is that the user can select the
type of authentication they want to use and then we'll switch the
relevant authenticator in as active. this means the new authenticator
takes over the prompting and stuff, allowing us to very flexibly add
more type configuration even when they require prompting because we only
ever have one active type. this gets augmented by the fingerprint
handling which may be active on any number of types as secondary log in
option. this is for convenience more than anything.

because we now switch authenticators at will that means we also need
them to cancel! to make this work reliably we are isolating the actual
pam_authenticate calls in standalone processes. communication happens
over dbus peer to peer.

to assist with visualizing this a new PAMAuthenticatorModel is being
introduced which describes the available authenticators for
visualization

M  +14   -22   greeter/CMakeLists.txt
M  +6    -16   greeter/greeterapp.cpp
M  +30   -268  greeter/pamauthenticator.cpp
M  +38   -6    greeter/pamauthenticator.h
A  +46   -0    greeter/pamauthenticatordescriptor.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +43   -0    greeter/pamauthenticatordescriptor.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +130  -0    greeter/pamauthenticatormodel.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +33   -0    greeter/pamauthenticatormodel.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
M  +202  -119  greeter/pamauthenticators.cpp
M  +27   -4    greeter/pamauthenticators.h
A  +214  -0    greeter/pamworker.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +65   -0    greeter/pamworker.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +56   -0    greeter/worker/CMakeLists.txt
A  +21   -0    greeter/worker/config-worker.h.in
A  +27   -0    greeter/worker/diewithparent.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +405  -0    greeter/worker/main.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +44   -0    greeter/worker/org.kde.plasma.screenlocker.worker.xml
A  +78   -0    greeter/worker/org.kde.plasma.screenlocker.xml
A  +14   -0    greeter/worker/result.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]

https://invent.kde.org/plasma/kscreenlocker/-/commit/a5ed9ca05964b6e394fc172014fedf5f4dda509a

diff --git a/greeter/CMakeLists.txt b/greeter/CMakeLists.txt
index df3ad663..64726387 100644
--- a/greeter/CMakeLists.txt
+++ b/greeter/CMakeLists.txt
@@ -6,11 +6,16 @@ include_directories(
     ${CMAKE_CURRENT_BINARY_DIR}/../
 )
 
+add_subdirectory(worker)
+
 set(kscreenlocker_authenticator_SRCS
     pamauthenticators.cpp
     pamauthenticators.h
     pamauthenticator.cpp
     pamauthenticator.h
+    pamauthenticatormodel.cpp
+    pamworker.cpp
+    pamauthenticatordescriptor.cpp
 )
 
 ecm_qt_declare_logging_category(kscreenlocker_authenticator_SRCS
@@ -39,11 +44,20 @@ ecm_qt_declare_logging_category(kscreenlocker_greet_SRCS
     EXPORT KSCREENLOCKER
 )
 
+set_source_files_properties(worker/org.kde.plasma.screenlocker.worker.xml PROPERTIES INCLUDE "result.h")
+qt_add_dbus_interface(kscreenlocker_authenticator_SRCS worker/org.kde.plasma.screenlocker.worker.xml org.kde.plasma.screenlocker.worker)
+qt_add_dbus_adaptor(kscreenlocker_authenticator_SRCS worker/org.kde.plasma.screenlocker.xml pamworker.h PamWorker)
+
 add_library(kscreenlocker_authenticator OBJECT ${kscreenlocker_authenticator_SRCS})
 target_link_libraries(kscreenlocker_authenticator
+    WorkerResult
     Qt::Core
     Qt::Qml
+    Qt::DBus
     ${PAM_LIBRARIES}
+    KF6::CoreAddons
+    KF6::ConfigCore
+    KF6::I18n
 )
 
 qt_add_resources(kscreenlocker_greet_SRCS fallbacktheme.qrc)
@@ -70,27 +84,6 @@ target_link_libraries(kscreenlocker_greet PRIVATE
     KF6::ScreenDpms
 )
 
-# KSCREENLOCKER_PAM_SERVICE, if defined, will already have been
-# enclosed in double quotes by the define_pam_service macro.
-if (NOT DEFINED KSCREENLOCKER_PAM_PASSWORD_SERVICE)
-  set(KSCREENLOCKER_PAM_PASSWORD_SERVICE "\"kde\"")
-endif()
-
-if (NOT DEFINED KSCREENLOCKER_PAM_FINGERPRINT_SERVICE)
-  set(KSCREENLOCKER_PAM_FINGERPRINT_SERVICE "\"kde-fingerprint\"")
-endif()
-
-if (NOT DEFINED KSCREENLOCKER_PAM_SMARTCARD_SERVICE)
-  set(KSCREENLOCKER_PAM_SMARTCARD_SERVICE "\"kde-smartcard\"")
-endif()
-
-target_compile_definitions(kscreenlocker_greet PRIVATE
-    KCHECKPASS_BIN="kcheckpass"
-    KSCREENLOCKER_PAM_SERVICE=${KSCREENLOCKER_PAM_SERVICE}
-    KSCREENLOCKER_PAM_FINGERPRINT_SERVICE=${KSCREENLOCKER_PAM_FINGERPRINT_SERVICE}
-    KSCREENLOCKER_PAM_SMARTCARD_SERVICE=${KSCREENLOCKER_PAM_SMARTCARD_SERVICE}
-)
-
 qt_generate_foreign_qml_types(kscreenlocker_authenticator kscreenlocker_greet)
 
 # manually install type info since it's not installed by default for executables but we want it
@@ -104,5 +97,4 @@ qt6_query_qml_module(kscreenlocker_greet
 install(FILES "${qml_module_qmldir}"   DESTINATION ${KDE_INSTALL_QMLDIR}/${qml_module_target_path})
 install(FILES "${qml_module_typeinfo}" DESTINATION ${KDE_INSTALL_QMLDIR}/${qml_module_target_path})
 
-
 install(TARGETS kscreenlocker_greet DESTINATION ${KDE_INSTALL_LIBEXECDIR})
diff --git a/greeter/greeterapp.cpp b/greeter/greeterapp.cpp
index b2005c75..30c9e145 100644
--- a/greeter/greeterapp.cpp
+++ b/greeter/greeterapp.cpp
@@ -167,16 +167,7 @@ UnlockApp::UnlockApp(int &argc, char **argv)
 {
     KLocalization::setupLocalizedContext(m_engine.get());
 
-    auto interactive = std::make_unique<PamAuthenticator>(QStringLiteral(KSCREENLOCKER_PAM_SERVICE), KUser().loginName());
-    std::vector<std::unique_ptr<PamAuthenticator>> noninteractive;
-    noninteractive.push_back(
-        std::make_unique<PamAuthenticator>(QStringLiteral(KSCREENLOCKER_PAM_FINGERPRINT_SERVICE), KUser().loginName(), PamAuthenticator::Fingerprint));
-    noninteractive.push_back(
-        std::make_unique<PamAuthenticator>(QStringLiteral(KSCREENLOCKER_PAM_SMARTCARD_SERVICE), KUser().loginName(), PamAuthenticator::Smartcard));
-    m_authenticators = new PamAuthenticators(std::move(interactive), std::move(noninteractive), this);
-    connect(m_logindIntegration, &LogindIntegration::prepareForSleep, m_authenticators, [this] {
-        m_authenticators->cancel();
-    });
+    m_authenticators = new PamAuthenticators(KUser().loginName(), this);
 
     auto state = m_engine->singletonInstance<LockscreenState *>("org.kde.kscreenlocker", "LockscreenState");
     state->init(this);
@@ -323,13 +314,12 @@ QQuickView *UnlockApp::createViewForScreen(QScreen *screen)
     // engine stuff
     QQmlContext *context = view->engine()->rootContext();
     connect(view->engine(), &QQmlEngine::quit, this, [this]() {
+        qCDebug(KSCREENLOCKER_GREET) << "Greeter quit signal received, checking if we are unlocked";
         if (m_authenticators->isUnlocked()) {
-            std::cout << "Unlocked" << std::endl;
-            // Quit without exit handlers
-            // This is because:
-            // - the pam_unix backend will always report a failed login if we complete the converse method no matter what exit code we use
-            // - the fprintd backend sometimes takes a long time
-            _exit(0);
+            qCDebug(KSCREENLOCKER_GREET) << "Unlocked";
+            // Mind that we can quit properly. All the blocking PAM tech is in subprocesses that will get reaped either
+            // by destructors or by themselves once they notice we are gone.
+            qApp->quit();
         } else {
             qCWarning(KSCREENLOCKER_GREET) << "Greeter tried to quit without being unlocked";
         }
diff --git a/greeter/pamauthenticator.cpp b/greeter/pamauthenticator.cpp
index 2f0acdce..32c454f8 100644
--- a/greeter/pamauthenticator.cpp
+++ b/greeter/pamauthenticator.cpp
@@ -1,265 +1,21 @@
 /*
     SPDX-FileCopyrightText: 2020 David Edmundson <[email protected]>
+    SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
 
     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
 */
 
 #include "pamauthenticator.h"
 
-#include <algorithm>
-
-#include <QDebug>
-#include <QEventLoop>
+#include <QElapsedTimer>
 #include <QMetaMethod>
 #include <QThread>
-#include <QTimer>
-#include <security/pam_appl.h>
 
-#include "kscreenlocker_greet_logging.h"
+#include "pamworker.h"
 
+using namespace std::chrono_literals;
 using namespace Qt::StringLiterals;
 
-namespace
-{
-template<typename Output, typename Input>
-Output narrow(Input i)
-{
-    Output o = i;
-    if (i != Input(o)) {
-        std::abort();
-    }
-    if (const auto sameSignedness = (std::is_signed_v<Input> && std::is_signed_v<Output>); !sameSignedness && ((i < Input{}) != (o < Output{}))) {
-        std::abort();
-    }
-    return o;
-}
-} // namespace
-
-class PamWorker : public QObject
-{
-    Q_OBJECT
-public:
-    PamWorker();
-    ~PamWorker() override;
-    Q_DISABLE_COPY_MOVE(PamWorker)
-    void start(const QString &service, const QString &user);
-    void authenticate();
-    void startFailedDelay(uint useconds);
-
-Q_SIGNALS:
-    void busyChanged(bool busy);
-    void promptForSecret(const QString &msg);
-    void prompt(const QString &msg);
-    void infoMessage(const QString &msg);
-    void errorMessage(const QString &msg);
-    void failed();
-    void loginFailedDelayStarted(const uint uSecDelay);
-    void succeeded();
-    void unavailabilityChanged(bool unavailable);
-    void inAuthenticateChanged(bool inAuthenticate);
-    void inPasswordDelayChanged(bool timeout);
-
-    // internal
-    void promptResponseReceived(const QByteArray &prompt);
-    void cancelled();
-
-private:
-    static int converse(int n, const struct pam_message **msg, struct pam_response **resp, void *data);
-
-    pam_handle_t *m_handle = nullptr; //< the actual PAM handle
-    struct pam_conv m_conv;
-
-    bool m_unavailable = false;
-    bool m_inAuthenticate = false;
-    std::chrono::steady_clock::time_point m_nextAttemptAllowedTime;
-    int m_result = -1;
-    QString m_service;
-};
-
-int PamWorker::converse(int n, const struct pam_message **msg, struct pam_response **resp, void *data)
-{
-    PamWorker *c = static_cast<PamWorker *>(data);
-
-    if (!resp) {
-        qCWarning(KSCREENLOCKER_GREET) << "[PAM worker] Converse called with null resp pointer";
-        return PAM_BUF_ERR;
-    }
-
-    const auto nSize = narrow<size_t>(n);
-
-    *resp = static_cast<struct pam_response *>(calloc(n, sizeof(struct pam_response)));
-    auto responses = std::span{*resp, nSize};
-
-    auto messages = std::span{msg, nSize};
-    Q_ASSERT_X(responses.size() == messages.size(), Q_FUNC_INFO, "Number of PAM messages and responses should be the same");
-
-    for (const auto &[pamMessage, pamResponse] : std::views::zip(messages, responses)) {
-        bool isSecret = false;
-        switch (pamMessage->msg_style) {
-        case PAM_PROMPT_ECHO_OFF: {
-            isSecret = true;
-            Q_FALLTHROUGH();
-        case PAM_PROMPT_ECHO_ON:
-            Q_EMIT c->busyChanged(false);
-
-            const QString prompt = QString::fromLocal8Bit(pamMessage->msg);
-            if (isSecret) {
-                Q_EMIT c->promptForSecret(prompt);
-            } else {
-                Q_EMIT c->prompt(prompt);
-            }
-
-            qCDebug(KSCREENLOCKER_GREET,
-                    "[PAM worker %s] Message: %s: %s",
-                    qUtf8Printable(c->m_service),
-                    (isSecret ? "Echo-off prompt" : "Echo-on prompt"),
-                    qUtf8Printable(prompt));
-
-            QByteArray response;
-            QEventLoop e;
-            QObject::connect(c, &PamWorker::promptResponseReceived, &e, [&](const QByteArray &_response) {
-                qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Received response, exiting nested event loop", qUtf8Printable(c->m_service));
-                response = _response;
-                e.exit(0);
-            });
-            QObject::connect(c, &PamWorker::cancelled, &e, [&]() {
-                qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Received cancellation, exiting with PAM_CONV_ERR", qUtf8Printable(c->m_service));
-                e.exit(PAM_CONV_ERR);
-            });
-
-            qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Starting nested event loop to await response", qUtf8Printable(c->m_service));
-            // We are in a non-gui thread. It should be mostly fine to exec() here.
-            int rc = e.exec();
-            if (rc != 0) {
-                qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Nested event loop's exit code was not zero, bailing", qUtf8Printable(c->m_service));
-                return rc;
-            }
-
-            Q_EMIT c->busyChanged(true);
-
-            const auto responseLengthIncludingNull = response.length() + 1; // QByteArray holds an implicit \0 at the end.
-            pamResponse.resp = static_cast<char *>(malloc(responseLengthIncludingNull));
-            std::copy_n(response.constData(), responseLengthIncludingNull, pamResponse.resp);
-
-            break;
-        }
-        case PAM_ERROR_MSG: {
-            const QString error = QString::fromLocal8Bit(pamMessage->msg);
-            qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Message: Error message: %s", qUtf8Printable(c->m_service), qUtf8Printable(error));
-            Q_EMIT c->errorMessage(error);
-            break;
-        }
-        case PAM_TEXT_INFO: {
-            // if there's only the info message, let's predict the prompts too
-            const QString info = QString::fromLocal8Bit(pamMessage->msg);
-            qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Message: Info message: %s", qUtf8Printable(c->m_service), qUtf8Printable(info));
-            Q_EMIT c->infoMessage(info);
-            break;
-        }
-        default:
-            qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Message: Unhandled message type: %d", qUtf8Printable(c->m_service), pamMessage->msg_style);
-            break;
-        }
-    }
-
-    return PAM_SUCCESS;
-}
-
-PamWorker::PamWorker()
-    : QObject(nullptr)
-    , m_conv({&PamWorker::converse, this})
-    , m_nextAttemptAllowedTime(std::chrono::steady_clock::now())
-{
-}
-
-PamWorker::~PamWorker()
-{
-    if (m_handle) {
-        pam_end(m_handle, PAM_SUCCESS);
-    }
-}
-
-void PamWorker::authenticate()
-{
-    if (m_inAuthenticate || m_unavailable) {
-        return;
-    }
-    m_inAuthenticate = true;
-    Q_EMIT inAuthenticateChanged(m_inAuthenticate);
-    qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] Authenticate: Starting authentication", qUtf8Printable(m_service));
-    int rc = pam_authenticate(m_handle, 0); // PAM_SILENT);
-    qCDebug(KSCREENLOCKER_GREET,
-            "[PAM worker %s] Authenticate: Authentication done, result code: %d (%s)",
-            qUtf8Printable(m_service),
-            rc,
-            pam_strerror(m_handle, rc));
-
-    if (rc == PAM_SUCCESS) {
-        pam_setcred(m_handle, PAM_REFRESH_CRED);
-        /* ignore errors on refresh credentials. If this did not work we use the old ones. */
-        Q_EMIT succeeded();
-    } else if (rc == PAM_AUTHINFO_UNAVAIL || rc == PAM_MODULE_UNKNOWN) {
-        m_unavailable = true;
-        Q_EMIT unavailabilityChanged(m_unavailable);
-    } else {
-        Q_EMIT failed();
-    }
-    Q_EMIT busyChanged(false);
-    m_inAuthenticate = false;
-    Q_EMIT inAuthenticateChanged(m_inAuthenticate);
-}
-
-void PamWorker::startFailedDelay(uint useconds)
-{
-    m_nextAttemptAllowedTime = std::chrono::steady_clock::now() + std::chrono::microseconds(useconds);
-    Q_EMIT inPasswordDelayChanged(true);
-    QTimer::singleShot(useconds / 1000, this, [this]() {
-        Q_EMIT inPasswordDelayChanged(false);
-    });
-    Q_EMIT loginFailedDelayStarted(useconds);
-}
-
-static void fail_delay(int retval, unsigned usec_delay, void *appdata_ptr)
-{
-    auto* worker = reinterpret_cast< PamWorker* >(appdata_ptr); // Refer the pam_conv (@sa m_conv) structure for info on appdata_ptr
-    if (!worker) {
-        qCFatal(KSCREENLOCKER_GREET) << "[PAM worker] appdata_ptr not convertible to a valid PamWorker! Cannot apply fail delay";
-        return;
-    }
-    if (retval == PAM_SUCCESS) {
-        qCDebug(KSCREENLOCKER_GREET) << "[PAM worker] Fail delay function was called, but authentication result was a success!";
-        return;
-    }
-    worker->startFailedDelay(usec_delay);
-}
-
-void PamWorker::start(const QString &service, const QString &user)
-{
-    m_service = service;
-    if (user.isEmpty())
-        m_result = pam_start(qPrintable(service), nullptr, &m_conv, &m_handle);
-    else
-        m_result = pam_start(qPrintable(service), qPrintable(user), &m_conv, &m_handle);
-
-    // get errors quicker
-#if defined(HAVE_PAM_FAIL_DELAY)
-    pam_set_item(m_handle, PAM_FAIL_DELAY, reinterpret_cast< void* >(fail_delay));
-#else
-    Q_UNUSED(fail_delay);
-#endif
-
-    if (m_result != PAM_SUCCESS) {
-        qCWarning(KSCREENLOCKER_GREET,
-                  "[PAM worker %s] start: error starting, result code: %d (%s)",
-                  qUtf8Printable(m_service),
-                  m_result,
-                  pam_strerror(m_handle, m_result));
-        return;
-    } else {
-        qCDebug(KSCREENLOCKER_GREET, "[PAM worker %s] start: successfully started", qUtf8Printable(m_service));
-    }
-}
-
 PamAuthenticator::PamAuthenticator(const QString &service, const QString &user, NoninteractiveAuthenticatorTypes types, QObject *parent)
     : QObject(parent)
     , m_signalsToMembers({
@@ -270,12 +26,9 @@ PamAuthenticator::PamAuthenticator(const QString &service, const QString &user,
       })
     , m_service(service)
     , m_authenticatorType(types)
-    , d(new PamWorker)
+    , m_thread(std::make_unique<PamWorker>(service, user))
+    , d(m_thread.worker())
 {
-    d->moveToThread(&m_thread);
-
-    connect(&m_thread, &QThread::finished, d, &QObject::deleteLater);
-
     connect(d, &PamWorker::busyChanged, this, &PamAuthenticator::setBusy);
     connect(d, &PamWorker::inPasswordDelayChanged, this, &PamAuthenticator::setInPasswordDelay);
     connect(d, &PamWorker::prompt, this, [this](const QString &msg) {
@@ -295,8 +48,7 @@ PamAuthenticator::PamAuthenticator(const QString &service, const QString &user,
         Q_EMIT errorMessage(msg);
     });
 
-    connect(d, &PamWorker::inAuthenticateChanged, this, [this](bool isInAuthenticate) {
-        m_inAuthentication = isInAuthenticate;
+    connect(d, &PamWorker::inAuthenticateChanged, this, [this] {
         Q_EMIT availableChanged();
     });
     connect(d, &PamWorker::unavailabilityChanged, this, [this](bool isUnavailable) {
@@ -314,31 +66,27 @@ PamAuthenticator::PamAuthenticator(const QString &service, const QString &user,
     connect(d, &PamWorker::loginFailedDelayStarted, this, &PamAuthenticator::loginFailedDelayStarted);
 
     m_thread.start();
-    init(service, user);
+
+    QMetaObject::invokeMethod(d, [this]() {
+        d->start();
+    });
 }
 
 PamAuthenticator::~PamAuthenticator()
 {
-    cancel();
+    // This is a special thread type that cleans up the worker before returning to us.
     m_thread.quit();
     m_thread.wait();
 }
 
-void PamAuthenticator::init(const QString &service, const QString &user)
-{
-    QMetaObject::invokeMethod(d, [this, service, user]() {
-        d->start(service, user);
-    });
-}
-
 bool PamAuthenticator::isBusy() const
 {
     return m_busy;
 }
 
-bool PamAuthenticator::isAvailable() const
+[[nodiscard]] bool PamAuthenticator::isAvailable() const
 {
-    return m_inAuthentication && !m_unavailable;
+    return !m_unavailable;
 }
 
 PamAuthenticator::NoninteractiveAuthenticatorTypes PamAuthenticator::authenticatorType() const
@@ -420,6 +168,20 @@ void PamAuthenticator::setInPasswordDelay(bool timeout)
     Q_EMIT inPasswordDelayChanged();
 }
 
-#include "pamauthenticator.moc"
+PamAuthenticator::WorkerThread::WorkerThread(std::unique_ptr<PamWorker> &&worker, QObject *parent)
+    : QThread(parent)
+    , m_worker(std::move(worker))
+{
+    m_worker->moveToThread(this);
+}
 
-#include "moc_pamauthenticator.cpp"
+[[nodiscard]] PamWorker *PamAuthenticator::WorkerThread::worker() const
+{
+    return m_worker.get();
+}
+
+void PamAuthenticator::WorkerThread::run()
+{
+    QThread::run();
+    m_worker.reset();
+}
diff --git a/greeter/pamauthenticator.h b/greeter/pamauthenticator.h
index 2519b20f..d3831a38 100644
--- a/greeter/pamauthenticator.h
+++ b/greeter/pamauthenticator.h
@@ -1,11 +1,13 @@
 /*
     SPDX-FileCopyrightText: 2020 David Edmundson <[email protected]>
+    SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
 
     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
 */
 
 #pragma once
 
+#include <QDateTime>
 #include <QObject>
 #include <QThread>
 #include <qqmlregistration.h>
@@ -32,10 +34,16 @@ class PamAuthenticator : public QObject
     Q_PROPERTY(bool unlocked READ isUnlocked NOTIFY succeeded)
 
 public:
+    /*!
+        Exists purely to not have to change the UI code as of Plasma 6.8. Could eventually be dropped in favor of the
+        PamAuthenticators::Authenticator enum.
+     */
     enum NoninteractiveAuthenticatorType {
         None = 0,
         Fingerprint = 1 << 0,
         Smartcard = 2 << 0,
+        Face = 3 << 0,
+        Universal2Factor = 4 << 0,
     };
     Q_DECLARE_FLAGS(NoninteractiveAuthenticatorTypes, NoninteractiveAuthenticatorType)
     Q_FLAG(NoninteractiveAuthenticatorTypes)
@@ -47,9 +55,23 @@ public:
     ~PamAuthenticator() override;
     Q_DISABLE_COPY_MOVE(PamAuthenticator)
 
+    /*!
+        Whether the Authenticator is currently doing something. This mostly means it is currently authenticating.
+        A busy Authenticator probably won't be able to act on further tryUnlock calls.
+     */
     bool isBusy() const;
+
+    /*!
+        Whether the Authenticator has successfully completed tryUnlock. i.e. the PAM service actually unlocked the account
+     */
     bool isUnlocked() const;
-    bool isAvailable() const;
+
+    /*!
+        Is this Authenticator actually available. An Authenticator goes unavailable when the underlying PAM service
+        malfunctions and terminates too quickly, or when it reports itself PAM_AUTHINFO_UNAVAIL.
+     */
+    [[nodiscard]] bool isAvailable() const;
+
     NoninteractiveAuthenticatorTypes authenticatorType() const;
 
     // Get prefix to de-duplicate from their signals.
@@ -81,9 +103,6 @@ public Q_SLOTS:
     void respond(const QByteArray &response);
     void cancel();
 
-protected:
-    void init(const QString &service, const QString &user);
-
 private:
     void setBusy(bool busy);
 
@@ -96,11 +115,24 @@ private:
     QString m_service;
     bool m_busy = false;
     bool m_unlocked = false;
-    bool m_inAuthentication = false;
     bool m_unavailable = false;
     bool m_inPasswordDelay = false;
     NoninteractiveAuthenticatorTypes m_authenticatorType;
-    QThread m_thread;
+    // Tiny problem with bare bones QThread: when we shut down we want to clean up
+    // our subprocess correctly, but doing that means running a function on the thread
+    // before terminating it. This doesn't work out of the box because ther are
+    // no facilities to effectively invokeMethod while the QApplication is already
+    // mid shutdown. Instead we have a custom thread type that calls cleanup on the worker.
+    class WorkerThread : public QThread
+    {
+    public:
+        WorkerThread(std::unique_ptr<PamWorker> &&worker, QObject *parent = nullptr);
+        [[nodiscard]] PamWorker *worker() const;
+        void run() override;
+
+    private:
+        std::unique_ptr<PamWorker> m_worker;
+    } m_thread;
     PamWorker *d;
 };
 
diff --git a/greeter/pamauthenticatordescriptor.cpp b/greeter/pamauthenticatordescriptor.cpp
new file mode 100644
index 00000000..9b839e06
--- /dev/null
+++ b/greeter/pamauthenticatordescriptor.cpp
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#include "pamauthenticatordescriptor.h"
+
+#include <QDebug>
+
+PAMAuthenticatorDescriptor::PAMAuthenticatorDescriptor(bool enabled,
+                                                       PamAuthenticators::Authenticator type,
+                                                       const QString &iconName,
+                                                       bool passwordField,
+                                                       bool expectingPrompt,
+                                                       const QString &tooltip,
+                                                       QObject *parent)
+    : QObject(parent)
+    , m_enabled(enabled)
+    , m_type(type)
+    , m_iconName(iconName)
+    , m_passwordField(passwordField)
+    , m_expectingPrompt(expectingPrompt)
+    , m_tooltip(tooltip)
+{
+}
+
+[[nodiscard]] bool PAMAuthenticatorDescriptor::isEnabled() const
+{
+    return m_enabled;
+}
+
+[[nodiscard]] PamAuthenticators::Authenticator PAMAuthenticatorDescriptor::type() const
+{
+    return m_type;
+}
+
+[[nodiscard]] bool PAMAuthenticatorDescriptor::isFunctional() const
+{
+    return m_functional;
+}
+
+void PAMAuthenticatorDescriptor::setFunctional(bool functioning)
+{
+    if (m_functional != functioning) {
+        m_functional = functioning;
+        Q_EMIT functionalChanged();
+    }
+}
diff --git a/greeter/pamauthenticatordescriptor.h b/greeter/pamauthenticatordescriptor.h
new file mode 100644
index 00000000..24c78af5
--- /dev/null
+++ b/greeter/pamauthenticatordescriptor.h
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#pragma once
+
+#include "pamauthenticators.h"
+
+class PAMAuthenticatorDescriptor : public QObject
+{
+    Q_OBJECT
+    Q_PROPERTY(bool enabled MEMBER m_enabled CONSTANT)
+    Q_PROPERTY(PamAuthenticators::Authenticator type MEMBER m_type CONSTANT)
+    Q_PROPERTY(QString iconName MEMBER m_iconName CONSTANT)
+    Q_PROPERTY(bool passwordField MEMBER m_passwordField CONSTANT)
+    Q_PROPERTY(bool expectingPrompt MEMBER m_expectingPrompt CONSTANT)
+    Q_PROPERTY(QString tooltip MEMBER m_tooltip CONSTANT)
+    Q_PROPERTY(bool functional MEMBER m_functional NOTIFY functionalChanged)
+public:
+    PAMAuthenticatorDescriptor() = default;
+    explicit PAMAuthenticatorDescriptor(bool enabled,
+                                        PamAuthenticators::Authenticator type,
+                                        const QString &iconName,
+                                        bool passwordField,
+                                        bool expectingPrompt,
+                                        const QString &tooltip,
+                                        QObject *parent = nullptr);
+    [[nodiscard]] bool isEnabled() const;
+    [[nodiscard]] PamAuthenticators::Authenticator type() const;
+    [[nodiscard]] bool isFunctional() const;
+    void setFunctional(bool functional);
+
+Q_SIGNALS:
+    void functionalChanged();
+
+private:
+    bool m_enabled;
+    PamAuthenticators::Authenticator m_type;
+    QString m_iconName;
+    bool m_passwordField;
+    bool m_expectingPrompt;
+    QString m_tooltip;
+    bool m_functional = true; // always functional by default
+};
diff --git a/greeter/pamauthenticatormodel.cpp b/greeter/pamauthenticatormodel.cpp
new file mode 100644
index 00000000..bd52af3a
--- /dev/null
+++ b/greeter/pamauthenticatormodel.cpp
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#include "pamauthenticatormodel.h"
+
+#include <QQmlEngine>
+
+#include <KConfig>
+#include <KConfigGroup>
+#include <KLocalizedString>
+
+#include "config-worker.h"
+#include "kscreenlocker_greet_logging.h"
+#include "pamauthenticatordescriptor.h"
+
+using namespace Qt::StringLiterals;
+
+namespace
+{
+
+[[nodiscard]] std::vector<std::shared_ptr<PAMAuthenticatorDescriptor>> makeDescriptors()
+{
+    auto isStaticallyEnabled = [](const auto &string) {
+        constexpr auto disabled = "disabled"_L1;
+        if (string.size() != disabled.size()) {
+            return true;
+        }
+        return std::ranges::all_of(std::views::zip(string, disabled), [](const auto &element) {
+            if (auto &[aCharacter, bCharacter] = element; aCharacter != bCharacter) {
+                return true;
+            }
+            return false;
+        });
+    };
+
+    constexpr auto fingerprintEnabled = isStaticallyEnabled(KSCREENLOCKER_PAM_FINGERPRINT_SERVICE);
+    constexpr auto faceEnabled = isStaticallyEnabled(KSCREENLOCKER_PAM_FACE_SERVICE);
+    constexpr auto universal2factorEnabled = isStaticallyEnabled(KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE);
+    constexpr auto smartcardEnabled = isStaticallyEnabled(KSCREENLOCKER_PAM_SMARTCARD_SERVICE);
+
+    KConfig config(u"kscreenlockerrc"_s);
+    const auto authenticators = config.group(u"Authenticators"_s);
+
+    auto all = std::initializer_list{
+        std::make_shared<PAMAuthenticatorDescriptor>(true, // must be always available
+                                                     PamAuthenticators::Authenticator::Regular,
+                                                     u"input-keyboard-symbolic"_s,
+                                                     true,
+                                                     true,
+                                                     i18nc("authentication type in unlock dialogs", "Password")),
+        std::make_shared<PAMAuthenticatorDescriptor>(smartcardEnabled && authenticators.readEntry("Smartcard", false),
+                                                     PamAuthenticators::Authenticator::Smartcard,
+                                                     u"secure-card-symbolic"_s,
+                                                     true,
+                                                     true,
+                                                     i18nc("authentication type in unlock dialogs", "Smartcard")),
+        std::make_shared<PAMAuthenticatorDescriptor>(fingerprintEnabled && authenticators.readEntry("Fingerprint", false),
+                                                     PamAuthenticators::Authenticator::Fingerprint,
+                                                     u"fingerprint-symbolic"_s,
+                                                     false,
+                                                     false,
+                                                     i18nc("authentication type in unlock dialogs", "Fingerprint")),
+        std::make_shared<PAMAuthenticatorDescriptor>(faceEnabled && authenticators.readEntry("Face", false),
+                                                     PamAuthenticators::Authenticator::Face,
+                                                     u"edit-image-face-detect-symbolic"_s,
+                                                     false,
+                                                     false,
+                                                     i18nc("authentication type in unlock dialogs - facial authentication", "Face")),
+        std::make_shared<PAMAuthenticatorDescriptor>(
+            universal2factorEnabled && authenticators.readEntry("Universal2Factor", false),
+            PamAuthenticators::Authenticator::Universal2Factor,
+            u"database-change-key-symbolic"_s,
+            false,
+            false,
+            i18nc("authentication type in unlock dialogs - universal 2 factor authentication (yubikey etc)", "Universal 2 Factor")),
+    };
+
+    auto view = all | std::views::filter([](const auto &d) {
+                    return d->isEnabled();
+                });
+    return {view.begin(), view.end()};
+}
+
+} // namespace
+
+// Treat the properties as data not columns.
+template<>
+struct QRangeModel::RowOptions<PAMAuthenticatorDescriptor> {
+    [[maybe_unused]] static constexpr auto rowCategory = QRangeModel::RowCategory::MultiRoleItem;
+};
+
+PAMAuthenticatorModel *PAMAuthenticatorModel::create([[maybe_unused]] QQmlEngine *qmlEngine, [[maybe_unused]] QJSEngine *jsEngine)
+{
+    auto model = instance();
+    QQmlEngine::setObjectOwnership(model, QQmlEngine::CppOwnership);
+    return model;
+}
+
+void PAMAuthenticatorModel::markDefunct(PamAuthenticators::Authenticator authenticator) const
+{
+    if (authenticator == PamAuthenticators::Authenticator::Regular) {
+        qCWarning(KSCREENLOCKER_GREET) << "Regular authenticator is defunct. This is unexpected and we ignore it.";
+        return;
+    }
+    m_hash.value(authenticator)->setFunctional(false);
+}
+
+[[nodiscard]] bool PAMAuthenticatorModel::isFunctional(PamAuthenticators::Authenticator authenticator) const
+{
+    return m_hash.value(authenticator)->isFunctional();
+}
+
+PAMAuthenticatorModel *PAMAuthenticatorModel::instance()
+{
+    static PAMAuthenticatorModel model(makeDescriptors(), nullptr);
+    return &model;
+}
+
+PAMAuthenticatorModel::PAMAuthenticatorModel(const Range &range, QObject *parent)
+    : QRangeModel(range, parent)
+    , m_hash([range] {
+        TypeHash hash;
+        for (const auto &descriptor : range) {
+            hash[descriptor->type()] = descriptor;
+        }
+        return hash;
+    }())
+{
+    QRangeModel::setAutoConnectPolicy(QRangeModel::AutoConnectPolicy::Full);
+}
diff --git a/greeter/pamauthenticatormodel.h b/greeter/pamauthenticatormodel.h
new file mode 100644
index 00000000..bfff1f10
--- /dev/null
+++ b/greeter/pamauthenticatormodel.h
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#pragma once
+
+#include <QRangeModel>
+#include <qqmlintegration.h>
+
+#include "pamauthenticators.h"
+
+class QQmlEngine;
+class QJSEngine;
+class PAMAuthenticatorDescriptor;
+
+class PAMAuthenticatorModel : public QRangeModel
+{
+    Q_OBJECT
+    QML_SINGLETON
+    QML_NAMED_ELEMENT(AuthenticatorModel)
+public:
+    [[nodiscard]] static PAMAuthenticatorModel *instance();
+    [[nodiscard]] static PAMAuthenticatorModel *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine);
+
+    // Helpers for PamAuthenticators accessing our internals. Bit out of place here but convenient.
+    void markDefunct(PamAuthenticators::Authenticator authenticator) const;
+    [[nodiscard]] bool isFunctional(PamAuthenticators::Authenticator authenticator) const;
+
+private:
+    using Range = std::vector<std::shared_ptr<PAMAuthenticatorDescriptor>>;
+    using TypeHash = QHash<PamAuthenticators::Authenticator, std::shared_ptr<PAMAuthenticatorDescriptor>>;
+    explicit PAMAuthenticatorModel(const Range &range, QObject *parent = nullptr);
+    TypeHash m_hash;
+};
diff --git a/greeter/pamauthenticators.cpp b/greeter/pamauthenticators.cpp
index c2cd5908..1ed81c5c 100644
--- a/greeter/pamauthenticators.cpp
+++ b/greeter/pamauthenticators.cpp
@@ -1,19 +1,30 @@
 /*
     SPDX-FileCopyrightText: 2023 Janet Blackquill <[email protected]>
+    SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
 
     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
 */
 
 #include <QDebug>
+#include <QMetaEnum>
+#include <QtConcurrent/QtConcurrentRun>
 #include <algorithm>
 
+#include <KConfig>
+#include <KConfigGroup>
+#include <KSharedConfig>
+
+#include "config-worker.h"
 #include "kscreenlocker_greet_logging.h"
 #include "pamauthenticator.h"
+#include "pamauthenticatormodel.h"
 #include "pamauthenticators.h"
 
+using namespace Qt::StringLiterals;
+
 struct PamAuthenticators::Private {
-    std::unique_ptr<PamAuthenticator> interactive;
-    std::vector<std::unique_ptr<PamAuthenticator>> noninteractive;
+    std::unique_ptr<PamAuthenticator> m_activeAuthenticator = nullptr;
+    std::unique_ptr<PamAuthenticator> m_fingerprintAuthenticator = nullptr;
     PamAuthenticator::NoninteractiveAuthenticatorTypes computedTypes = PamAuthenticator::NoninteractiveAuthenticatorType::None;
     AuthenticatorsState state = AuthenticatorsState::Idle;
     bool graceLocked = false;
@@ -21,121 +32,46 @@ struct PamAuthenticators::Private {
 
     void recomputeNoninteractiveAuthenticationTypes()
     {
-        PamAuthenticator::NoninteractiveAuthenticatorTypes result = PamAuthenticator::NoninteractiveAuthenticatorType::None;
-        for (auto &&noninteractive : noninteractive) {
-            if (noninteractive->isAvailable()) {
-                result |= noninteractive->authenticatorType();
-            }
-        }
-        computedTypes = result;
-    }
-    void cancelNoninteractive()
-    {
-        for (auto &&noninteractive : noninteractive) {
-            noninteractive->cancel();
+        if (!m_fingerprintAuthenticator || !m_fingerprintAuthenticator->isAvailable()) {
+            return;
         }
+
+        computedTypes = PamAuthenticator::NoninteractiveAuthenticatorType::Fingerprint;
     }
 };
 
-PamAuthenticators::PamAuthenticators(std::unique_ptr<PamAuthenticator> &&interactive,
-                                     std::vector<std::unique_ptr<PamAuthenticator>> &&noninteractive,
-                                     QObject *parent)
+PamAuthenticators::PamAuthenticators(const QString &loginName, QObject *parent)
     : QObject(parent)
-    , d(new Private{std::move(interactive), std::move(noninteractive)})
+    , m_loginName(loginName)
+    , d(new Private)
 {
-    connect(d->interactive.get(), &PamAuthenticator::succeeded, this, [this] {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Success from interactive authenticator" << qUtf8Printable(d->interactive->service());
-        Q_EMIT succeeded();
-    });
-    connect(d->interactive.get(), &PamAuthenticator::failed, this, [this] {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Failure from interactive authenticator" << qUtf8Printable(d->interactive->service());
-        setState(AuthenticatorsState::Idle);
-        d->cancelNoninteractive();
-        Q_EMIT failed(PamAuthenticator::NoninteractiveAuthenticatorType::None, d->interactive.get());
-    });
-    connect(d->interactive.get(), &PamAuthenticator::loginFailedDelayStarted, this, [this](const uint uSecDelay) noexcept -> void {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Delay started on login failure for interactive authenticator" << qUtf8Printable(d->interactive->service())
-        << "duration:" << uSecDelay;
-        Q_EMIT loginFailedDelayStarted(PamAuthenticator::NoninteractiveAuthenticatorType::None, d->interactive.get(), uSecDelay);
-    });
-    for (auto &&noninteractive : d->noninteractive) {
-        connect(noninteractive.get(), &PamAuthenticator::succeeded, this, [this, &noninteractive] {
-            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Success from non-interactive authenticator" << qUtf8Printable(noninteractive->service());
-            Q_EMIT succeeded();
-        });
-        connect(noninteractive.get(), &PamAuthenticator::availableChanged, this, [this, &noninteractive] {
-            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Availability changed for non-interactive authenticator"
-                                         << qUtf8Printable(noninteractive->service()) << noninteractive->isAvailable();
-            d->recomputeNoninteractiveAuthenticationTypes();
-            Q_EMIT authenticatorTypesChanged();
-        });
-        connect(noninteractive.get(), &PamAuthenticator::failed, this, [this, &noninteractive] {
-            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Non-interactive authenticator" << qUtf8Printable(noninteractive->service()) << "failed";
-            Q_EMIT failed(noninteractive->authenticatorType(), noninteractive.get());
-        });
-        connect(noninteractive.get(), &PamAuthenticator::loginFailedDelayStarted, this, [this, &noninteractive](const uint uSecDelay) noexcept -> void {
-            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Delay started on login failure for non-interactive authenticator" << qUtf8Printable(noninteractive->service())
-            << "duration:" << uSecDelay;
-            Q_EMIT loginFailedDelayStarted(noninteractive->authenticatorType(), noninteractive.get(), uSecDelay);
-        });
-        connect(noninteractive.get(), &PamAuthenticator::infoMessage, this, [this, &noninteractive]() {
-            if (!d->hadPrompt) {
-                d->hadPrompt = true;
-                Q_EMIT hadPromptChanged();
-            }
-            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Info message from non-interactive authenticator" << qUtf8Printable(noninteractive->service());
-            Q_EMIT noninteractiveInfo(noninteractive->authenticatorType(), noninteractive.get());
-        });
-        connect(noninteractive.get(), &PamAuthenticator::errorMessage, this, [this, &noninteractive]() {
-            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Error message from non-interactive authenticator " << qUtf8Printable(noninteractive->service());
-            Q_EMIT noninteractiveError(noninteractive->authenticatorType(), noninteractive.get());
-        });
-    }
+    connect(this, &PamAuthenticators::authenticatorChanged, this, &PamAuthenticators::onAuthenticatorChanged);
+    QMetaObject::invokeMethod(this, &PamAuthenticators::onAuthenticatorChanged, Qt::QueuedConnection);
+}
 
-    // connect the delegated signals
-    connect(d->interactive.get(), &PamAuthenticator::busyChanged, this, [this] {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Interactive authenticator" << qUtf8Printable(d->interactive->service()) << "changed business";
-        Q_EMIT busyChanged();
-    });
-    connect(d->interactive.get(), &PamAuthenticator::inPasswordDelayChanged, this, [this] {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Interactive authenticator" << qUtf8Printable(d->interactive->service()) << "changed pam timeout";
-        Q_EMIT inPasswordDelayChanged();
-    });
-    connect(d->interactive.get(), &PamAuthenticator::prompt, this, [this] {
-        if (!d->hadPrompt) {
-            d->hadPrompt = true;
-            Q_EMIT hadPromptChanged();
-        }
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Normal prompt from interactive authenticator" << qUtf8Printable(d->interactive->service());
-        Q_EMIT promptChanged();
-    });
-    connect(d->interactive.get(), &PamAuthenticator::promptForSecret, this, [this] {
-        if (!d->hadPrompt) {
-            d->hadPrompt = true;
-            Q_EMIT hadPromptChanged();
-        }
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Secret prompt from interactive authenticator" << qUtf8Printable(d->interactive->service());
-        Q_EMIT promptForSecretChanged();
-    });
-    connect(d->interactive.get(), &PamAuthenticator::infoMessage, this, [this] {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Info message from interactive authenticator" << qUtf8Printable(d->interactive->service());
-        Q_EMIT infoMessageChanged();
-    });
-    connect(d->interactive.get(), &PamAuthenticator::errorMessage, this, [this] {
-        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Error message from interactive authenticator" << qUtf8Printable(d->interactive->service());
-        Q_EMIT errorMessageChanged();
-    });
+PamAuthenticators::~PamAuthenticators() = default;
+
+PamAuthenticators::Authenticator PamAuthenticators::loadAuthenticatorType()
+{
+    auto authenticators = QMetaEnum::fromType<Authenticator>();
+    auto group = KSharedConfig::openStateConfig(u"kscreenlockerrc"_s)->group(u"Greeter"_s);
+    auto authenticatorKey = group.readEntry("Authenticator", authenticators.valueToKey(std::to_underlying(Authenticator::Regular)));
+    auto authenticatorValue = authenticators.keysToValue(authenticatorKey.toUtf8().constData());
+    return static_cast<Authenticator>(authenticatorValue);
 }
 
-PamAuthenticators::~PamAuthenticators()
+void PamAuthenticators::saveAuthenticatorType(Authenticator authenticator)
 {
+    // This only ought to happen when successfully unlocked using this authenticator. Not when switching to it!
+    auto authenticators = QMetaEnum::fromType<Authenticator>();
+    auto group = KSharedConfig::openStateConfig(u"kscreenlockerrc"_s)->group(u"Greeter"_s);
+    group.writeEntry("Authenticator", authenticators.valueToKey(std::to_underlying(authenticator)));
+    group.sync();
 }
 
 bool PamAuthenticators::isUnlocked() const
 {
-    return d->interactive->isUnlocked() || std::any_of(d->noninteractive.cbegin(), d->noninteractive.cend(), [](auto &&t) {
-               return t->isUnlocked();
-           });
+    return d->m_activeAuthenticator->isUnlocked() || d->m_fingerprintAuthenticator->isUnlocked();
 }
 
 PamAuthenticators::AuthenticatorsState PamAuthenticators::state() const
@@ -145,14 +81,17 @@ PamAuthenticators::AuthenticatorsState PamAuthenticators::state() const
 
 void PamAuthenticators::startAuthenticating()
 {
-    if (d->state == AuthenticatorsState::Authenticating || d->graceLocked) {
+    if (d->graceLocked) {
         return;
     }
 
-    qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: starting authenticators";
-    d->interactive->tryUnlock();
-    for (auto &&noninteractive : d->noninteractive) {
-        noninteractive->tryUnlock();
+    // Never allow a malfunctioning authenticator to unlock unless the state resets. This prevents us from looping a broken authenticator.
+    if (PAMAuthenticatorModel::instance()->isFunctional(m_authenticator) && d->m_activeAuthenticator) {
+        d->m_activeAuthenticator->tryUnlock();
+    }
+    if (d->m_fingerprintAuthenticator
+        && d->m_fingerprintAuthenticator->isAvailable() /* the implicit fingerprint authenticator has no descriptor in the model */) {
+        d->m_fingerprintAuthenticator->tryUnlock();
     }
     setState(AuthenticatorsState::Authenticating);
 }
@@ -160,10 +99,12 @@ void PamAuthenticators::startAuthenticating()
 void PamAuthenticators::stopAuthenticating()
 {
     qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: stopping authenticators";
-    for (auto &&noninteractive : d->noninteractive) {
-        noninteractive->cancel();
+    if (d->m_activeAuthenticator) {
+        d->m_activeAuthenticator->cancel();
+    }
+    if (d->m_fingerprintAuthenticator) {
+        d->m_fingerprintAuthenticator->cancel();
     }
-    d->interactive->cancel();
     setState(AuthenticatorsState::Idle);
 }
 
@@ -177,48 +118,190 @@ void PamAuthenticators::setState(AuthenticatorsState state)
     Q_EMIT stateChanged();
 }
 
+void PamAuthenticators::onAuthenticatorChanged()
+{
+    qCWarning(KSCREENLOCKER_GREET) << "PamAuthenticators: Authenticator changed to" << m_authenticator;
+    if (d->m_activeAuthenticator) {
+        d->m_activeAuthenticator->disconnect();
+        d->m_activeAuthenticator->cancel();
+        d->m_activeAuthenticator.reset();
+    }
+
+    if (m_authenticator == Authenticator::Fingerprint && d->m_fingerprintAuthenticator) {
+        d->m_fingerprintAuthenticator->disconnect();
+        d->m_fingerprintAuthenticator->cancel();
+        d->m_fingerprintAuthenticator.reset();
+    } else if (!d->m_fingerprintAuthenticator) {
+        d->m_fingerprintAuthenticator = std::make_unique<PamAuthenticator>(KSCREENLOCKER_PAM_FINGERPRINT_SERVICE, m_loginName, PamAuthenticator::Fingerprint);
+        connect(d->m_fingerprintAuthenticator.get(), &PamAuthenticator::succeeded, this, [this] {
+            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Success from non-interactive authenticator" << qUtf8Printable(d->m_fingerprintAuthenticator->service());
+            saveAuthenticatorType(m_authenticator);
+            Q_EMIT succeeded();
+        });
+        connect(d->m_fingerprintAuthenticator.get(), &PamAuthenticator::availableChanged, this, [this] {
+            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Availability changed for non-interactive authenticator"
+                                         << qUtf8Printable(d->m_fingerprintAuthenticator->service()) << d->m_fingerprintAuthenticator->isAvailable();
+            d->recomputeNoninteractiveAuthenticationTypes();
+            // Mind that this is the "implicit" fingerprint reader. It has no descriptor and consequently doesn't need marking defunct.
+            Q_EMIT authenticatorTypesChanged();
+        });
+        connect(d->m_fingerprintAuthenticator.get(), &PamAuthenticator::failed, this, [this] {
+            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Non-interactive authenticator" << qUtf8Printable(d->m_fingerprintAuthenticator->service()) << "failed";
+            Q_EMIT failed(d->m_fingerprintAuthenticator->authenticatorType(), d->m_fingerprintAuthenticator.get());
+        });
+        connect(d->m_fingerprintAuthenticator.get(), &PamAuthenticator::loginFailedDelayStarted, this, [this](const uint uSecDelay) noexcept -> void {
+            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Delay started on login failure for non-interactive authenticator"
+                                         << qUtf8Printable(d->m_fingerprintAuthenticator->service()) << "duration:" << uSecDelay;
+            Q_EMIT loginFailedDelayStarted(d->m_fingerprintAuthenticator->authenticatorType(), d->m_fingerprintAuthenticator.get(), uSecDelay);
+        });
+        connect(d->m_fingerprintAuthenticator.get(), &PamAuthenticator::infoMessage, this, [this]() {
+            if (!d->hadPrompt) {
+                d->hadPrompt = true;
+                Q_EMIT hadPromptChanged();
+            }
+            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Info message from non-interactive authenticator" << qUtf8Printable(d->m_fingerprintAuthenticator->service());
+            Q_EMIT noninteractiveInfo(d->m_fingerprintAuthenticator->authenticatorType(), d->m_fingerprintAuthenticator.get());
+        });
+        connect(d->m_fingerprintAuthenticator.get(), &PamAuthenticator::errorMessage, this, [this]() {
+            qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Error message from non-interactive authenticator " << qUtf8Printable(d->m_fingerprintAuthenticator->service());
+            Q_EMIT noninteractiveError(d->m_fingerprintAuthenticator->authenticatorType(), d->m_fingerprintAuthenticator.get());
+        });
+    }
+
+    d->m_activeAuthenticator = [this] {
+        Q_ASSERT_X(!m_loginName.isEmpty(), Q_FUNC_INFO, "login name must be set before constructing authenticators");
+        auto make_unique = [this](const QString &service, PamAuthenticator::NoninteractiveAuthenticatorType type) {
+            return std::make_unique<PamAuthenticator>(service, m_loginName, type);
+        };
+        switch (m_authenticator) {
+        case Authenticator::Regular:
+            return make_unique(KSCREENLOCKER_PAM_PASSWORD_SERVICE, PamAuthenticator::None);
+        case Authenticator::Fingerprint:
+            return make_unique(KSCREENLOCKER_PAM_FINGERPRINT_SERVICE, PamAuthenticator::Fingerprint);
+        case Authenticator::Smartcard:
+            return make_unique(KSCREENLOCKER_PAM_SMARTCARD_SERVICE, PamAuthenticator::Smartcard);
+        case Authenticator::Face:
+            return make_unique(KSCREENLOCKER_PAM_FACE_SERVICE, PamAuthenticator::Face);
+        case Authenticator::Universal2Factor:
+            return make_unique(KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE, PamAuthenticator::Universal2Factor);
+        }
+        Q_ASSERT_X(false, Q_FUNC_INFO, "unhandled authenticator type");
+        return std::unique_ptr<PamAuthenticator>{};
+    }();
+    // Old one got deleted and thus disconnected. Let's connect the new one!
+
+    auto authenticator = d->m_activeAuthenticator.get();
+    connect(authenticator, &PamAuthenticator::succeeded, this, [this, authenticator] {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Success from interactive authenticator" << qUtf8Printable(authenticator->service());
+        saveAuthenticatorType(m_authenticator);
+        Q_EMIT succeeded();
+    });
+    connect(authenticator, &PamAuthenticator::availableChanged, this, [this, authenticator] {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Availability changed for interactive authenticator" << qUtf8Printable(authenticator->service())
+                                     << authenticator->isAvailable();
+        d->recomputeNoninteractiveAuthenticationTypes();
+        if (!authenticator->isAvailable()) {
+            PAMAuthenticatorModel::instance()->markDefunct(m_authenticator);
+        }
+        Q_EMIT authenticatorTypesChanged();
+    });
+    connect(authenticator, &PamAuthenticator::failed, this, [this, authenticator] {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Failure from interactive authenticator" << qUtf8Printable(authenticator->service());
+        setState(AuthenticatorsState::Idle);
+        Q_EMIT failed(PamAuthenticator::NoninteractiveAuthenticatorType::None, authenticator);
+    });
+    connect(authenticator, &PamAuthenticator::loginFailedDelayStarted, this, [this, authenticator](const uint uSecDelay) noexcept -> void {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Delay started on login failure for interactive authenticator" << qUtf8Printable(authenticator->service())
+        << "duration:" << uSecDelay;
+        Q_EMIT loginFailedDelayStarted(PamAuthenticator::NoninteractiveAuthenticatorType::None, authenticator, uSecDelay);
+    });
+    connect(authenticator, &PamAuthenticator::busyChanged, this, [this, authenticator] {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Interactive authenticator" << qUtf8Printable(authenticator->service()) << "changed business";
+        Q_EMIT busyChanged();
+    });
+    connect(authenticator, &PamAuthenticator::prompt, this, [this, authenticator] {
+        if (!d->hadPrompt) {
+            d->hadPrompt = true;
+            Q_EMIT hadPromptChanged();
+        }
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Normal prompt from interactive authenticator" << qUtf8Printable(authenticator->service());
+        Q_EMIT promptChanged();
+    });
+    connect(authenticator, &PamAuthenticator::promptForSecret, this, [this, authenticator] {
+        if (!d->hadPrompt) {
+            d->hadPrompt = true;
+            Q_EMIT hadPromptChanged();
+        }
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Secret prompt from interactive authenticator" << qUtf8Printable(authenticator->service());
+        Q_EMIT promptForSecretChanged();
+    });
+    connect(authenticator, &PamAuthenticator::infoMessage, this, [this, authenticator] {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Info message from interactive authenticator" << qUtf8Printable(authenticator->service());
+        Q_EMIT infoMessageChanged();
+    });
+    connect(authenticator, &PamAuthenticator::errorMessage, this, [this, authenticator] {
+        qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: Error message from interactive authenticator" << qUtf8Printable(authenticator->service());
+        Q_EMIT errorMessageChanged();
+    });
+
+    d->graceLocked = false;
+    // graceLock has no signal
+    d->hadPrompt = false;
+    Q_EMIT hadPromptChanged();
+    d->state = AuthenticatorsState::Idle;
+    Q_EMIT stateChanged();
+
+    Q_EMIT promptChanged();
+    Q_EMIT promptForSecretChanged();
+    Q_EMIT infoMessageChanged();
+    Q_EMIT errorMessageChanged();
+    Q_EMIT busyChanged();
+
+    startAuthenticating();
+}
+
 // these properties are delegated to interactive authenticator
 
 bool PamAuthenticators::isBusy() const
 {
-    return d->interactive->isBusy();
+    return d->m_activeAuthenticator->isBusy();
 }
 
 bool PamAuthenticators::inPasswordDelay() const
 {
-    return d->interactive->inPasswordDelay();
+    return d->m_activeAuthenticator->inPasswordDelay();
 }
 
 QString PamAuthenticators::prompt() const
 {
-    return d->interactive->getPrompt();
+    return d->m_activeAuthenticator->getPrompt();
 }
 
 QString PamAuthenticators::promptForSecret() const
 {
-    return d->interactive->getPromptForSecret();
+    return d->m_activeAuthenticator->getPromptForSecret();
 }
 
 QString PamAuthenticators::infoMessage() const
 {
-    return d->interactive->getInfoMessage();
+    return d->m_activeAuthenticator->getInfoMessage();
 }
 
 QString PamAuthenticators::errorMessage() const
 {
-    return d->interactive->getErrorMessage();
+    return d->m_activeAuthenticator->getErrorMessage();
 }
 
 void PamAuthenticators::respond(const QByteArray &response)
 {
     qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: responding to interactive authenticator";
-    d->interactive->respond(response);
+    d->m_activeAuthenticator->respond(response);
 }
 
 void PamAuthenticators::cancel()
 {
     qCDebug(KSCREENLOCKER_GREET) << "PamAuthenticators: cancelling interactive authenticator";
-    d->interactive->cancel();
+    d->m_activeAuthenticator->cancel();
 }
 
 PamAuthenticator::NoninteractiveAuthenticatorTypes PamAuthenticators::authenticatorTypes() const
diff --git a/greeter/pamauthenticators.h b/greeter/pamauthenticators.h
index 7ffd6218..a12105d5 100644
--- a/greeter/pamauthenticators.h
+++ b/greeter/pamauthenticators.h
@@ -1,5 +1,6 @@
 /*
     SPDX-FileCopyrightText: 2023 Janet Blackquill <[email protected]>
+    SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
 
     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
 */
@@ -7,6 +8,7 @@
 #pragma once
 
 #include "pamauthenticator.h"
+
 #include <QObject>
 #include <memory>
 #include <qqmlregistration.h>
@@ -30,17 +32,31 @@ class PamAuthenticators : public QObject
     // this property true if any of the authenticators' unlocked properties are true
     Q_PROPERTY(bool unlocked READ isUnlocked NOTIFY succeeded)
 
-    // this property is a sum of the noninteractive authenticators' flags
+    /*!
+        \qmlproperty Authenticator.NoninteractiveAuthenticatorTypes Authenticators::authenticatorTypes
+
+        This purely exists for backwards compatiblity in the plasma-desktop QML code!
+        This property is either Fingerprint or nothing.
+        Prefer the newer Authenticators::authenticator property instead.
+    */
     Q_PROPERTY(PamAuthenticator::NoninteractiveAuthenticatorTypes authenticatorTypes READ authenticatorTypes NOTIFY authenticatorTypesChanged)
 
     Q_PROPERTY(AuthenticatorsState state READ state NOTIFY stateChanged)
 
     Q_PROPERTY(bool hadPrompt READ hadPrompt NOTIFY hadPromptChanged)
+    Q_PROPERTY(Authenticator authenticator MEMBER m_authenticator NOTIFY authenticatorChanged)
 
 public:
-    PamAuthenticators(std::unique_ptr<PamAuthenticator> &&interactive,
-                      std::vector<std::unique_ptr<PamAuthenticator>> &&noninteractive,
-                      QObject *parent = nullptr);
+    enum class Authenticator {
+        Regular,
+        Fingerprint,
+        Smartcard,
+        Face,
+        Universal2Factor,
+    };
+    Q_ENUM(Authenticator)
+
+    PamAuthenticators(const QString &loginName, QObject *parent = nullptr);
     ~PamAuthenticators() override;
 
     enum AuthenticatorsState {
@@ -89,8 +105,15 @@ public:
 
     bool hadPrompt() const;
     Q_SIGNAL void hadPromptChanged();
+    Q_SIGNAL void authenticatorChanged();
 
 private:
+    void onAuthenticatorChanged();
+    Authenticator loadAuthenticatorType();
+    void saveAuthenticatorType(Authenticator authenticator);
+
+    QString m_loginName;
+    Authenticator m_authenticator = loadAuthenticatorType();
     struct Private;
     QScopedPointer<Private> d;
 
diff --git a/greeter/pamworker.cpp b/greeter/pamworker.cpp
new file mode 100644
index 00000000..012e123a
--- /dev/null
+++ b/greeter/pamworker.cpp
@@ -0,0 +1,214 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2020 David Edmundson <[email protected]>
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#include "pamworker.h"
+
+#include <QDBusConnection>
+#include <QDBusMessage>
+#include <QDBusPendingCall>
+#include <QDBusServer>
+#include <QProcess>
+#include <QTimer>
+
+#include <KLibexec>
+
+#include "kscreenlocker_greet_logging.h"
+#include "result.h"
+#include "screenlockeradaptor.h"
+
+using namespace std::chrono_literals;
+using namespace Qt::StringLiterals;
+
+PamWorker::PamWorker(const QString &service, const QString &user)
+    : QObject(nullptr)
+    , m_nextAttemptAllowedTime(std::chrono::steady_clock::now())
+    , m_service(service)
+    , m_username(user)
+{
+    connect(this, &PamWorker::promptResponseReceived, this, [this](const QByteArray &response) {
+        if (m_pendingPrompt.type() != QDBusMessage::InvalidMessage) {
+            QDBusMessage reply = m_pendingPrompt.createReply(QString::fromUtf8(response));
+            m_dbusWorker->connection().send(reply);
+            m_pendingPrompt = {};
+        } else {
+            qCWarning(KSCREENLOCKER_GREET) << "Received prompt response, but no pending prompt was found!";
+        }
+    });
+
+    connect(this, &PamWorker::cancelled, this, [this] {
+        if (!m_dbusWorker) {
+            return;
+        }
+        // Timings here are tight because we may need to cancel two workers, we don't want to get stuck on this for too long.
+        // Let it quit on its own. Mind that this usually will get stuck because the worker is itself waiting for a prompt response.
+        std::ignore = m_dbusWorker->Cancel();
+        if (m_workerProcess) {
+            m_workerProcess->waitForFinished((5ms).count());
+        }
+        // Make sure everything is cleaned up properly
+        quitWorkerProcess();
+    });
+}
+
+PamWorker::~PamWorker()
+{
+    quitWorkerProcess();
+}
+
+void PamWorker::authenticate()
+{
+    if (m_inAuthenticate) {
+        // No need to log in this case. We call authenticate multiple times to keep the auth session running even
+        // when the backend (e.g. fprint) aborts things.
+        return;
+    }
+
+    if (!m_dbusWorker) {
+        qCWarning(KSCREENLOCKER_GREET) << "DBus worker not initialized, cannot authenticate yet. Retry in a bit.";
+        return;
+    }
+
+    if (m_unavailable) {
+        qCDebug(KSCREENLOCKER_GREET) << "PAM service is not available. Cannot authenticate.";
+        return;
+    }
+
+    m_inAuthenticate = true;
+    Q_EMIT inAuthenticateChanged(m_inAuthenticate);
+    Q_EMIT busyChanged(true);
+    auto scopedAuthenticate = qScopeGuard([this] {
+        m_inAuthenticate = false;
+        Q_EMIT inAuthenticateChanged(m_inAuthenticate);
+        Q_EMIT busyChanged(false);
+    });
+
+    auto watcher = new QDBusPendingCallWatcher(m_dbusWorker->Authenticate(), this);
+    connect(watcher, &QDBusPendingCallWatcher::finished, this, [scopedAuthenticate = std::move(scopedAuthenticate), watcher, this]() {
+        watcher->deleteLater();
+        if (watcher->error().isValid()) {
+            qCWarning(KSCREENLOCKER_GREET) << "PAM worker failed to authenticate:" << watcher->error().message();
+            Q_EMIT failed();
+            return;
+        }
+
+        decltype(m_dbusWorker->Authenticate()) reply = watcher->reply();
+        switch (reply.value()) {
+        case WorkerResult::Type::Failure:
+            Q_EMIT failed();
+            return;
+        case WorkerResult::Type::Success:
+            Q_EMIT succeeded();
+            return;
+        case WorkerResult::Type::Unavailable:
+            m_unavailable = true;
+            Q_EMIT unavailabilityChanged(m_unavailable);
+            return;
+        };
+
+        qWarning() << "Unexpected authentication result" << reply.value();
+    });
+}
+
+void PamWorker::StartFailedDelay(uint useconds)
+{
+    m_nextAttemptAllowedTime = std::chrono::steady_clock::now() + std::chrono::microseconds(useconds);
+    Q_EMIT inPasswordDelayChanged(true);
+    QTimer::singleShot(std::chrono::microseconds(useconds), this, [this]() {
+        Q_EMIT inPasswordDelayChanged(false);
+    });
+    Q_EMIT loginFailedDelayStarted(useconds);
+}
+
+void PamWorker::InfoMessage(const QString &msg)
+{
+    Q_EMIT infoMessage(msg);
+}
+
+void PamWorker::ErrorMessage(const QString &msg)
+{
+    Q_EMIT errorMessage(msg);
+}
+
+void PamWorker::start()
+{
+    Q_ASSERT(!m_workerProcess);
+
+    qCDebug(KSCREENLOCKER_GREET) << "Starting PAM worker for service" << m_service << "and user" << m_username;
+    new ScreenlockerAdaptor(this);
+
+    auto server = new QDBusServer(this);
+    QObject::connect(server, &QDBusServer::newConnection, this, [this](const QDBusConnection &connection) {
+        Q_ASSERT(!m_dbusWorker); // only accept a connection once to mitigate the risk of a malicious actor connecting to us
+        qCDebug(KSCREENLOCKER_GREET) << "New D-Bus connection established" << connection.name();
+        auto c = connection; // make a non-const copy
+        c.registerObject(u"/org/kde/plasma/screenlocker"_s, this, QDBusConnection::ExportAdaptors);
+        m_dbusWorker = std::make_unique<org::kde::plasma::screenlocker::worker>(QString(), u"/org/kde/plasma/screenlocker/worker"_s, c);
+        m_dbusWorker->setTimeout(std::numeric_limits<int>::max()); // disable timeout, we expect blocking calls to arrive eventually
+    });
+
+    m_workerProcess = new QProcess(this);
+    m_workerProcess->setProcessChannelMode(QProcess::ForwardedChannels);
+    m_workerProcess->setProgram(KLibexec::path(u"kscreenlocker_worker"_s));
+    m_workerProcess->setArguments({m_service});
+    m_workerProcess->start();
+    m_workerProcess->write(server->address().toUtf8());
+    m_workerProcess->closeWriteChannel();
+}
+
+void PamWorker::Ping(const QString &message)
+{
+    qCDebug(KSCREENLOCKER_GREET) << "Ping received" << message << calledFromDBus();
+    org::kde::plasma::screenlocker::worker worker(QString(), u"/org/kde/plasma/screenlocker/worker"_s, connection());
+    auto watcher = new QDBusPendingCallWatcher(worker.Start(m_service, m_username), this);
+    connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher]() {
+        watcher->deleteLater();
+        Q_ASSERT(watcher->isValid());
+    });
+}
+
+QString PamWorker::Prompt(const QString &msg)
+{
+    return promptInternal(msg, false);
+}
+
+QString PamWorker::MaskedPrompt(const QString &msg)
+{
+    return promptInternal(msg, true);
+}
+
+QString PamWorker::promptInternal(const QString &msg, bool isSecret)
+{
+    Q_EMIT busyChanged(false);
+
+    if (isSecret) {
+        Q_EMIT promptForSecret(msg);
+    } else {
+        Q_EMIT prompt(msg);
+    }
+
+    qCDebug(KSCREENLOCKER_GREET,
+            "[PAM worker %s] Message: %s: %s",
+            qUtf8Printable(m_service),
+            (isSecret ? "Echo-off prompt" : "Echo-on prompt"),
+            qUtf8Printable(msg));
+
+    setDelayedReply(true);
+
+    m_pendingPrompt = message();
+
+    return {};
+}
+
+void PamWorker::quitWorkerProcess()
+{
+    if (m_workerProcess) {
+        m_workerProcess->terminate();
+        if (!m_workerProcess->waitForFinished((25ms).count())) {
+            qWarning() << "Worker did not terminate in time, killing it.";
+            m_workerProcess->kill();
+        }
+    }
+    m_dbusWorker.reset();
+    m_workerProcess = nullptr;
+}
diff --git a/greeter/pamworker.h b/greeter/pamworker.h
new file mode 100644
index 00000000..ed2a9e71
--- /dev/null
+++ b/greeter/pamworker.h
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2020 David Edmundson <[email protected]>
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#pragma once
+
+#include <memory>
+
+#include <QDBusContext>
+#include <QDBusMessage>
+#include <QObject>
+
+#include "org.kde.plasma.screenlocker.worker.h"
+
+class QProcess;
+
+class PamWorker : public QObject, QDBusContext
+{
+    Q_OBJECT
+public:
+    PamWorker(const QString &service, const QString &user);
+    ~PamWorker() override;
+    Q_DISABLE_COPY_MOVE(PamWorker)
+    void start();
+    void authenticate();
+    void StartFailedDelay(uint useconds);
+    void InfoMessage(const QString &msg);
+    void ErrorMessage(const QString &msg);
+
+public Q_SLOTS:
+    void Ping(const QString &message);
+    [[nodiscard]] QString Prompt(const QString &msg);
+    [[nodiscard]] QString MaskedPrompt(const QString &msg);
+
+Q_SIGNALS:
+    void busyChanged(bool busy);
+    void promptForSecret(const QString &msg);
+    void prompt(const QString &msg);
+    void infoMessage(const QString &msg);
+    void errorMessage(const QString &msg);
+    void failed();
+    void loginFailedDelayStarted(const uint uSecDelay);
+    void succeeded();
+    void unavailabilityChanged(bool unavailable);
+    void inAuthenticateChanged(bool inAuthenticate);
+    void inPasswordDelayChanged(bool timeout);
+
+    // internal
+    void promptResponseReceived(const QByteArray &prompt);
+    void cancelled();
+
+private:
+    [[nodiscard]] QString promptInternal(const QString &msg, bool isSecret);
+    void quitWorkerProcess();
+
+    bool m_unavailable = false;
+    bool m_inAuthenticate = false;
+    std::chrono::steady_clock::time_point m_nextAttemptAllowedTime;
+    int m_result = -1;
+    QString m_service;
+    QString m_username;
+    QDBusMessage m_pendingPrompt;
+    std::unique_ptr<org::kde::plasma::screenlocker::worker> m_dbusWorker;
+    QProcess *m_workerProcess = nullptr;
+};
diff --git a/greeter/worker/CMakeLists.txt b/greeter/worker/CMakeLists.txt
new file mode 100644
index 00000000..8c627d67
--- /dev/null
+++ b/greeter/worker/CMakeLists.txt
@@ -0,0 +1,56 @@
+# SPDX-License-Identifier: BSD-2-Clause
+# SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+if(CMAKE_VERSION VERSION_GREATER_EQUAL 4.2)
+    # 4.2 has nicer API for CACHE entries. Let's use it!
+    set(CACHE{KSCREENLOCKER_PAM_PASSWORD_SERVICE}
+        TYPE STRING
+        HELP "The PAM service to use for password authentication. CANNOT be disabled!"
+        VALUE "kde")
+    set(CACHE{KSCREENLOCKER_PAM_FINGERPRINT_SERVICE}
+        TYPE STRING
+        HELP "The PAM service to use for fingerprint authentication. 'disabled' to disable"
+        VALUE "kde-fingerprint")
+    set(CACHE{KSCREENLOCKER_PAM_SMARTCARD_SERVICE}
+        TYPE STRING
+        HELP "The PAM service to use for smartcard authentication. 'disabled' to disable"
+        VALUE "kde-smartcard")
+    set(CACHE{KSCREENLOCKER_PAM_FACE_SERVICE}
+        TYPE STRING
+        HELP "The PAM service to use for face authentication. 'disabled' to disable"
+        VALUE "kde-face")
+    set(CACHE{KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE}
+        TYPE STRING
+        HELP "The PAM service to use for universal 2-factor authentication. 'disabled' to disable"
+        VALUE "kde-u2f")
+else()
+    # This can be dropped once our FreeBSD CI has 4.2+ (which should be around Plasma 6.9)
+    set(KSCREENLOCKER_PAM_PASSWORD_SERVICE "kde" CACHE STRING "The PAM service to use for password authentication. CANNOT be disabled!")
+    set(KSCREENLOCKER_PAM_FINGERPRINT_SERVICE "kde-fingerprint" CACHE STRING "The PAM service to use for fingerprint authentication. 'disabled' to disable")
+    set(KSCREENLOCKER_PAM_SMARTCARD_SERVICE "kde-smartcard" CACHE STRING "The PAM service to use for smartcard authentication. 'disabled' to disable")
+    set(KSCREENLOCKER_PAM_FACE_SERVICE "kde-face" CACHE STRING "The PAM service to use for face authentication. 'disabled' to disable")
+    set(KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE "kde-u2f" CACHE STRING "The PAM service to use for universal 2-factor authentication. 'disabled' to disable")
+endif()
+
+configure_file(config-worker.h.in ${CMAKE_CURRENT_BINARY_DIR}/config-worker.h)
+
+add_library(WorkerResult)
+target_include_directories(WorkerResult PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR};${CMAKE_CURRENT_BINARY_DIR}>")
+
+qt_add_dbus_interface(SRCS org.kde.plasma.screenlocker.xml org.kde.plasma.screenlocker)
+add_executable(kscreenlocker_worker main.cpp ${SRCS})
+target_link_libraries(kscreenlocker_worker
+    PRIVATE
+        WorkerResult
+        Qt::Core
+        Qt::DBus
+        ${PAM_LIBRARIES}
+)
+ecm_qt_declare_logging_category(kscreenlocker_worker
+    HEADER debug.h
+    IDENTIFIER DEFAULT_WORKER
+    CATEGORY_NAME kscreenlocker.worker
+    DESCRIPTION "KScreenLocker Worker"
+    EXPORT KSCREENLOCKER_WORKER
+)
+install(TARGETS kscreenlocker_worker DESTINATION ${KDE_INSTALL_LIBEXECDIR})
diff --git a/greeter/worker/config-worker.h.in b/greeter/worker/config-worker.h.in
new file mode 100644
index 00000000..df26e934
--- /dev/null
+++ b/greeter/worker/config-worker.h.in
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#pragma once
+
+#include <QLatin1String>
+
+constexpr auto KSCREENLOCKER_PAM_PASSWORD_SERVICE = QLatin1String("@KSCREENLOCKER_PAM_PASSWORD_SERVICE@");
+static_assert(!KSCREENLOCKER_PAM_PASSWORD_SERVICE.isEmpty(), "KSCREENLOCKER_PAM_PASSWORD_SERVICE must be defined");
+
+constexpr auto KSCREENLOCKER_PAM_FINGERPRINT_SERVICE = QLatin1String("@KSCREENLOCKER_PAM_FINGERPRINT_SERVICE@");
+static_assert(!KSCREENLOCKER_PAM_FINGERPRINT_SERVICE.isEmpty(), "KSCREENLOCKER_PAM_FINGERPRINT_SERVICE must be defined");
+
+constexpr auto KSCREENLOCKER_PAM_SMARTCARD_SERVICE = QLatin1String("@KSCREENLOCKER_PAM_SMARTCARD_SERVICE@");
+static_assert(!KSCREENLOCKER_PAM_SMARTCARD_SERVICE.isEmpty(), "KSCREENLOCKER_PAM_SMARTCARD_SERVICE must be defined");
+
+constexpr auto KSCREENLOCKER_PAM_FACE_SERVICE = QLatin1String("@KSCREENLOCKER_PAM_FACE_SERVICE@");
+static_assert(!KSCREENLOCKER_PAM_FACE_SERVICE.isEmpty(), "KSCREENLOCKER_PAM_FACE_SERVICE must be defined");
+
+constexpr auto KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE = QLatin1String("@KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE@");
+static_assert(!KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE.isEmpty(), "KSCREENLOCKER_PAM_UNIVERSAL2FACTOR_SERVICE must be defined");
diff --git a/greeter/worker/diewithparent.h b/greeter/worker/diewithparent.h
new file mode 100644
index 00000000..27d729e5
--- /dev/null
+++ b/greeter/worker/diewithparent.h
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#pragma once
+
+#include <QtGlobal>
+
+#if defined(Q_OS_FREEBSD)
+#include <sys/procctl.h>
+#else
+#include <sys/prctl.h>
+#endif
+
+#include <csignal>
+
+#if defined(Q_OS_FREEBSD)
+inline auto dieWithParent()
+{
+    auto sig = SIGKILL;
+    return procctl(P_PID, 0, PROC_PDEATHSIG_CTL, static_cast<void *>(&sig));
+}
+#else
+inline auto dieWithParent()
+{
+    return prctl(PR_SET_PDEATHSIG, SIGKILL);
+}
+#endif
diff --git a/greeter/worker/main.cpp b/greeter/worker/main.cpp
new file mode 100644
index 00000000..b0685d94
--- /dev/null
+++ b/greeter/worker/main.cpp
@@ -0,0 +1,405 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2020 David Edmundson <[email protected]>
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#include <security/pam_appl.h>
+#include <unistd.h>
+
+#include <iostream>
+
+#include <QCoreApplication>
+#include <QDBusConnection>
+#include <QDBusConnectionInterface>
+#include <QDBusMessage>
+#include <QDBusPendingCall>
+#include <QElapsedTimer>
+
+#include "config-worker.h"
+#include "debug.h"
+#include "diewithparent.h"
+#include "org.kde.plasma.screenlocker.h"
+#include "result.h"
+
+using namespace std::chrono_literals;
+using namespace Qt::StringLiterals;
+
+namespace std
+{
+template<>
+struct default_delete<pam_handle_t> {
+    void operator()(pam_handle_t *ptr) const
+    {
+        if (ptr) {
+            ::pam_end(ptr, PAM_SUCCESS);
+        }
+    }
+};
+} // namespace std
+
+namespace
+{
+
+// this is a non-const pointer, but also needs to be; it's effectively dependant on argv input.
+auto WORKER = DEFAULT_WORKER; // NOLINT
+
+template<typename Output, typename Input>
+[[nodiscard]] Output narrow(Input i)
+{
+    Output o = i;
+    if (i != Input(o)) {
+        std::abort();
+    }
+    if (const auto sameSignedness = (std::is_signed_v<Input> && std::is_signed_v<Output>); !sameSignedness && ((i < Input{}) != (o < Output{}))) {
+        std::abort();
+    }
+    return o;
+}
+
+class Worker : public QObject
+{
+    Q_OBJECT
+public:
+    Worker(bool fingerprint, org::kde::plasma::screenlocker *screenlocker);
+    void start(const QString &service, const QString &user);
+    [[nodiscard]] WorkerResult::Type authenticate();
+    void startFailedDelay(uint useconds);
+
+Q_SIGNALS:
+    void inAuthenticateChanged(bool inAuthenticate);
+
+    // internal
+    void promptResponseReceived(const QByteArray &prompt);
+    void cancelled();
+    void interrupt();
+
+private:
+    [[nodiscard]] static int converse(int n, const struct pam_message **msg, struct pam_response **resp, void *data);
+
+    bool m_fingerprint;
+    std::unique_ptr<pam_handle_t> m_handle = nullptr; //< the actual PAM handle
+    struct pam_conv m_conv;
+    bool m_available = true;
+    bool m_inAuthenticate = false;
+    int m_result = -1;
+    QString m_service;
+    org::kde::plasma::screenlocker *m_screenlocker;
+};
+
+class Adaptor : public QObject
+{
+    Q_OBJECT
+    Q_CLASSINFO("D-Bus Interface", "org.kde.plasma.screenlocker.worker")
+public:
+    Adaptor(org::kde::plasma::screenlocker &screenlocker)
+        : QObject(nullptr)
+        , m_screenlocker(screenlocker)
+    {
+    }
+
+public Q_SLOTS:
+    Q_NOREPLY void Start(const QString &service, const QString &username)
+    {
+        qCDebug(WORKER) << "Proxy: Start called with service" << service << "and username" << username;
+        m_worker = std::make_unique<Worker>(service == KSCREENLOCKER_PAM_FINGERPRINT_SERVICE, &m_screenlocker);
+        m_worker->start(service, username);
+    }
+
+    [[nodiscard]] int Authenticate()
+    {
+        qCDebug(WORKER) << "Proxy: Authenticate called";
+        return m_worker->authenticate();
+    }
+
+    Q_NOREPLY void Cancel()
+    {
+        qCDebug(WORKER) << "Proxy: Cancel called";
+        qApp->quit();
+    }
+
+private:
+    org::kde::plasma::screenlocker &m_screenlocker;
+    std::unique_ptr<Worker> m_worker;
+};
+
+void fail_delay(int retval, unsigned usec_delay, void *appdata_ptr)
+{
+    auto *worker = reinterpret_cast<Worker *>(appdata_ptr); // Refer the pam_conv (@sa m_conv) structure for info on appdata_ptr
+    if (!worker) {
+        qCFatal(WORKER) << "[PAM worker] appdata_ptr not convertible to a valid Worker! Cannot apply fail delay";
+        return;
+    }
+    if (retval == PAM_SUCCESS) {
+        qCDebug(WORKER) << "[PAM worker] Fail delay function was called, but authentication result was a success!";
+        return;
+    }
+    worker->startFailedDelay(usec_delay);
+}
+
+} // namespace
+
+int Worker::converse(int n, const struct pam_message **msg, struct pam_response **resp, void *data)
+{
+    auto c = static_cast<Worker *>(data);
+
+    if (!resp) {
+        qCWarning(WORKER) << "[PAM worker] Converse called with null resp pointer";
+        return PAM_BUF_ERR;
+    }
+
+    const auto nSize = narrow<size_t>(n);
+
+    *resp = static_cast<struct pam_response *>(calloc(n, sizeof(struct pam_response)));
+    auto responses = std::span{*resp, nSize};
+
+    auto messages = std::span{msg, nSize};
+    Q_ASSERT_X(responses.size() == messages.size(), Q_FUNC_INFO, "Number of PAM messages and responses should be the same");
+
+    for (const auto &[pamMessage, pamResponse] : std::views::zip(messages, responses)) {
+        bool isSecret = false;
+        switch (pamMessage->msg_style) {
+        case PAM_PROMPT_ECHO_OFF: {
+            isSecret = true;
+            Q_FALLTHROUGH();
+        case PAM_PROMPT_ECHO_ON:
+            const QString prompt = QString::fromLocal8Bit(pamMessage->msg);
+
+            qCDebug(WORKER,
+                    "[PAM worker %s] Message: %s: %s",
+                    qUtf8Printable(c->m_service),
+                    (isSecret ? "Echo-off prompt" : "Echo-on prompt"),
+                    qUtf8Printable(prompt));
+
+            const QString responseString = [&] {
+                if (isSecret) {
+                    QDBusPendingReply<QString> reply = c->m_screenlocker->MaskedPrompt(prompt);
+                    reply.waitForFinished();
+                    if (reply.error().type() == QDBusError::Disconnected) {
+                        _exit(0);
+                    }
+                    return reply.value();
+                }
+                return c->m_screenlocker->Prompt(prompt).value();
+            }();
+            const auto response = responseString.toUtf8();
+
+            const auto responseLengthIncludingNull = response.length() + 1; // QByteArray holds an implicit \0 at the end.
+            pamResponse.resp = static_cast<char *>(malloc(responseLengthIncludingNull));
+            std::copy_n(response.constData(), responseLengthIncludingNull, pamResponse.resp);
+
+            break;
+        }
+        case PAM_ERROR_MSG: {
+            const QString error = QString::fromLocal8Bit(pamMessage->msg);
+            qCDebug(WORKER, "[PAM worker %s] Message: Error message: %s", qUtf8Printable(c->m_service), qUtf8Printable(error));
+            c->m_screenlocker->ErrorMessage(error);
+            break;
+        }
+        case PAM_TEXT_INFO: {
+            // if there's only the info message, let's predict the prompts too
+            const QString info = QString::fromLocal8Bit(pamMessage->msg);
+            qCDebug(WORKER, "[PAM worker %s] Message: Info message: %s", qUtf8Printable(c->m_service), qUtf8Printable(info));
+            c->m_screenlocker->InfoMessage(info);
+            break;
+        }
+        default:
+            qCDebug(WORKER, "[PAM worker %s] Message: Unhandled message type: %d", qUtf8Printable(c->m_service), pamMessage->msg_style);
+            break;
+        }
+    }
+
+    return PAM_SUCCESS;
+}
+
+Worker::Worker(bool fingerprint, org::kde::plasma::screenlocker *screenlocker)
+    : QObject(nullptr)
+    , m_fingerprint(fingerprint)
+    , m_conv({.conv = &Worker::converse, .appdata_ptr = this})
+    , m_screenlocker(screenlocker)
+{
+}
+
+WorkerResult::Type Worker::authenticate()
+{
+    if (m_inAuthenticate) {
+        qCDebug(WORKER, "[PAM worker %s] Authentication is already in progress", qUtf8Printable(m_service));
+        return WorkerResult::Type::Failure;
+    }
+    if (!m_available) {
+        qCDebug(WORKER, "[PAM worker %s] PAM service is not available", qUtf8Printable(m_service));
+        return WorkerResult::Type::Failure;
+    }
+
+    m_inAuthenticate = true;
+    Q_EMIT inAuthenticateChanged(m_inAuthenticate);
+    auto scopedAuthenticate = qScopeGuard([this] {
+        m_inAuthenticate = false;
+        Q_EMIT inAuthenticateChanged(m_inAuthenticate);
+    });
+
+    qCDebug(WORKER, "[PAM worker %s] Authenticate: Starting authentication", qUtf8Printable(m_service));
+
+    QElapsedTimer timer;
+    timer.start();
+
+    int rc = pam_authenticate(m_handle.get(), 0); // PAM_SILENT);
+    qCDebug(WORKER, "[PAM worker %s] Authenticate: Authentication done, result code: %d (%s)", qUtf8Printable(m_service), rc, pam_strerror(m_handle.get(), rc));
+
+    qCWarning(WORKER) << timer.elapsed() << "ms elapsed during pam_authenticate call for service" << qUtf8Printable(m_service) << "with result code" << rc;
+
+    constexpr auto tooQuick = 50ms;
+    if (timer.durationElapsed() <= tooQuick) {
+        // This happened faster than is reasonable for any service -> let's mark as unavailable to avoid hammering a broken service with retries
+        // Has been observed with the vibe coded face authenticators on github. They will report success in 0ms when they are totally defunct.
+        qCWarning(WORKER) << "Unexpectedly short auth error on PAM service" << qUtf8Printable(m_service) << timer.durationElapsed();
+        m_available = false;
+        return WorkerResult::Type::Unavailable;
+    }
+
+    if (rc == PAM_SUCCESS) {
+        pam_setcred(m_handle.get(), PAM_REFRESH_CRED);
+        /* ignore errors on refresh credentials. If this did not work we use the old ones. */
+        return WorkerResult::Type::Success;
+    }
+
+    if (rc == PAM_AUTHINFO_UNAVAIL && m_fingerprint) {
+        // For fingerprint authentication, PAM_AUTHINFO_UNAVAIL can mean any number of things, but luckily most of them
+        // are fixed by simply restarting the authentication. The notable case we want to catch here is timeouts.
+        constexpr auto tooQuick = 250ms;
+        // Unless this error was returned suspiciously fast. Then it probably was an actual problem.
+        if (timer.durationElapsed() <= tooQuick) {
+            qCWarning(WORKER) << "Unexpectedly short auth error on fingerprint reader" << timer.durationElapsed();
+            m_available = false;
+            return WorkerResult::Type::Unavailable;
+        }
+        return WorkerResult::Type::Failure;
+    }
+
+    if (rc == PAM_AUTHINFO_UNAVAIL || rc == PAM_MODULE_UNKNOWN) {
+        // Explicitly unavailable -> let's mark as such
+        m_available = false;
+        return WorkerResult::Type::Unavailable;
+    }
+
+    return WorkerResult::Type::Failure;
+}
+
+void Worker::startFailedDelay(uint useconds)
+{
+    // Inform the frontend so it can make the UI appear blocked.
+    m_screenlocker->StartFailedDelay(useconds);
+    // To enforce the delay we'll simply go to sleep to prevent the frontend from actually doing anything.
+    QThread::sleep(std::chrono::microseconds(useconds));
+}
+
+void Worker::start(const QString &service, const QString &user)
+{
+    m_handle.reset([&] {
+        pam_handle_t *handle = nullptr;
+        if (user.isEmpty()) {
+            m_result = pam_start(qPrintable(service), nullptr, &m_conv, &handle);
+        } else {
+            m_result = pam_start(qPrintable(service), qPrintable(user), &m_conv, &handle);
+        }
+
+        if (m_result != PAM_SUCCESS) {
+            qCWarning(WORKER,
+                      "[PAM worker %s] start: error starting, result code: %d (%s)",
+                      qUtf8Printable(service),
+                      m_result,
+                      pam_strerror(m_handle.get(), m_result));
+            return handle;
+        }
+
+        qCDebug(WORKER, "[PAM worker %s] start: successfully started", qUtf8Printable(service));
+        return handle;
+    }());
+    m_service = service;
+
+    // get errors quicker
+#if defined(HAVE_PAM_FAIL_DELAY)
+    pam_set_item(m_handle.get(), PAM_FAIL_DELAY, reinterpret_cast<void *>(fail_delay));
+#else
+    Q_UNUSED(fail_delay);
+#endif
+
+    if (m_result != PAM_SUCCESS) {
+        qCWarning(WORKER,
+                  "[PAM worker %s] start: error starting, result code: %d (%s)",
+                  qUtf8Printable(m_service),
+                  m_result,
+                  pam_strerror(m_handle.get(), m_result));
+        return;
+    }
+
+    qCDebug(WORKER, "[PAM worker %s] start: successfully started", qUtf8Printable(m_service));
+}
+
+int main(int argc, char *argv[])
+{
+    dieWithParent();
+
+    QCoreApplication app(argc, argv);
+
+    qCDebug(WORKER) << "Worker is starting up.";
+
+    constexpr auto expectedArguments = 2; // [binary, pam-service-name]
+    if (app.arguments().size() < expectedArguments) {
+        qCWarning(WORKER) << "Worker was started without a service argument. This is wrong. Also, don't call this manually.";
+        return 1;
+    }
+    auto service = app.arguments().at(1); // the PAM service name (e.g. kde-fingerprint)
+
+    // Switch our QLoggingCategory to the correct service. This makes it clearer which PAM service we are working with.
+    //
+    // Mind that qstrdup calls new char[], so we need the ptr to delete [] as well, that is why the type is char[].
+    std::unique_ptr<char[]> serviceName(qstrdup(u"kscreenlocker.worker.pam-%1"_s.arg(service).toUtf8().constData()));
+    static const QLoggingCategory category(serviceName.get(), [] {
+        // QLoggingCategory doesn't have a way to get the set level. It only allows querying if a given level is enabled.
+        // Trouble is that this is cascading. When Warning is enabled then Critical is also, so we'd have to iterate in the correct order.
+        // BUT we cannot use QMetaEnum to iterate the enum because it is not ordered by importance.
+        // So here we are, manually calling the functions in the right order such that we inherit the right level. Meh.
+        if (DEFAULT_WORKER().isCriticalEnabled()) {
+            return QtCriticalMsg;
+        }
+        if (DEFAULT_WORKER().isWarningEnabled()) {
+            return QtWarningMsg;
+        }
+        if (DEFAULT_WORKER().isInfoEnabled()) {
+            return QtInfoMsg;
+        }
+        if (DEFAULT_WORKER().isDebugEnabled()) {
+            return QtDebugMsg;
+        }
+        return QtInfoMsg;
+    }());
+    WORKER = []() -> const QLoggingCategory & {
+        return category;
+    };
+
+    std::string address = [] {
+        std::string address;
+        while (address.empty()) {
+            std::getline(std::cin, address);
+        }
+        return address;
+    }();
+
+    auto connection = QDBusConnection::connectToPeer(QString::fromStdString(address), u"org.kde.plasma.screenlocker"_s);
+    org::kde::plasma::screenlocker screenlocker(QString(), u"/org/kde/plasma/screenlocker"_s, connection);
+    screenlocker.setTimeout(
+        std::numeric_limits<int>::max()); // disable timeout, we expect blocking calls to arrive eventually (or we get terminated by our parent)
+    Adaptor proxy(screenlocker);
+    connection.registerObject(u"/org/kde/plasma/screenlocker/worker"_s, &proxy, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals);
+    // Tell the screenlocker we are ready. This is necessary because there is technically a race between
+    // the connection getting established and us registering the object. To avoid any issues we have this
+    // explicit "go" call.
+    screenlocker.Ping(u"Hello from worker!"_s);
+
+    qCDebug(WORKER) << "Worker is now running, waiting for D-Bus calls.";
+
+    auto ret = app.exec();
+    qCDebug(WORKER) << "Worker is exiting with code" << ret;
+    return ret;
+}
+
+#include "main.moc"
diff --git a/greeter/worker/org.kde.plasma.screenlocker.worker.xml b/greeter/worker/org.kde.plasma.screenlocker.worker.xml
new file mode 100644
index 00000000..1ec6f8cd
--- /dev/null
+++ b/greeter/worker/org.kde.plasma.screenlocker.worker.xml
@@ -0,0 +1,44 @@
+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
+                      "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
+<!--
+  SPDX-License-Identifier: CC0-1.0
+  SPDX-FileCopyrightText: none
+-->
+<node>
+  <!--
+    org.kde.plasma.screenlocker.worker:
+    The interface of the worker subprocess. The greeter calls this interface to tell the worker to do work.
+  -->
+  <interface name="org.kde.plasma.screenlocker.worker">
+    <!--
+      Start:
+      @service: The pam service stack name (e.g. kde, or kde-fingerprint)
+      @username: The username to authenticate (or empty string to prompt nullptr)
+
+      This starts the pam stack. It's called in reaction to a Ping() on the greeter.
+      Ping essentially communicates that the worker subprocess is ready. Start()
+      communicates the greeter is also ready.
+    -->
+    <method name="Start">
+      <arg type="s" name="service" direction="in"/>
+      <arg type="s" name="username" direction="in"/>
+    </method>
+
+    <!--
+      Authenticate:
+      @result: 0 on success, 1 on failure, 2 on service stack being unavailable
+
+      The actual auth flow starts here. This method is blocking until auth is complete.
+      Make sure to set a suitably long bus timeout!
+    -->
+    <method name="Authenticate">
+      <arg type="i" name="result" direction="out"/>
+    </method>
+
+    <!--
+      Cancel:
+      This cancels an ongoing authentication. It is non-blocking.
+    -->
+    <method name="Cancel"/>
+  </interface>
+</node>
diff --git a/greeter/worker/org.kde.plasma.screenlocker.xml b/greeter/worker/org.kde.plasma.screenlocker.xml
new file mode 100644
index 00000000..ad6fe294
--- /dev/null
+++ b/greeter/worker/org.kde.plasma.screenlocker.xml
@@ -0,0 +1,78 @@
+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
+                      "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
+<!--
+  SPDX-License-Identifier: CC0-1.0
+  SPDX-FileCopyrightText: none
+-->
+<node>
+  <!--
+    org.kde.plasma.screenlocker:
+    The interface of the greeter. It is called by the worker subprocess to communicate and send updates from the pam stack.
+  -->
+  <interface name="org.kde.plasma.screenlocker">
+    <!--
+      Ping:
+      @message: Any message, not really important. Gets printed as debug message.
+
+      This communicates to the greeter that the worker subprocess is ready.
+      The greeter will then call Start() on the worker.
+    -->
+    <method name="Ping">
+      <arg type="s" name="message" direction="in"/>
+    </method>
+
+    <!--
+      Prompt:
+      @request: The prompt message to show to the user (e.g. "Password:")
+      @reply: The reply from the user (e.g. "hunter2")
+
+      This is issuing a non-masked prompt. i.e. the input characters may be echoed to the screen.
+    -->
+    <method name="Prompt">
+      <arg type="s" name="request" direction="in"/>
+      <arg type="s" name="reply" direction="out"/>
+    </method>
+
+    <!--
+      MaskedPrompt:
+      @request: The prompt message to show to the user (e.g. "Password:")
+      @reply: The reply from the user (e.g. "hunter2")
+
+      This is issuing a masked prompt. i.e. the input characters will **NOT** be echoed to the screen.
+    -->
+    <method name="MaskedPrompt">
+      <arg type="s" name="request" direction="in"/>
+      <arg type="s" name="reply" direction="out"/>
+    </method>
+
+    <!--
+      ErrorMessage:
+      @message: The error message to show to the user (e.g. "Incorrect password")
+
+      This is issuing an error message.
+    -->
+    <method name="ErrorMessage">
+      <arg type="s" name="message" direction="in"/>
+    </method>
+
+    <!--
+      InfoMessage:
+      @message: The informational message to show to the user (e.g. "Caps Lock is on")
+
+      This is issuing an informational message.
+    -->
+    <method name="InfoMessage">
+      <arg type="s" name="message" direction="in"/>
+    </method>
+
+    <!--
+      StartFailedDelay:
+      @useconds: The number of microseconds to delay after a failed start.
+
+      This is issuing a start failed delay.
+    -->
+    <method name="StartFailedDelay">
+      <arg type="u" name="useconds" direction="in"/>
+    </method>
+  </interface>
+</node>
diff --git a/greeter/worker/result.h b/greeter/worker/result.h
new file mode 100644
index 00000000..0538229f
--- /dev/null
+++ b/greeter/worker/result.h
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Harald Sitter <[email protected]>
+
+#pragma once
+
+namespace WorkerResult
+{
+enum Type { // auto-conversion from int doesn't want to work on the greeter side of things, consequently we are using an old school enum here and coerce it to
+            // int
+    Failure = 0,
+    Success = 1,
+    Unavailable = 2,
+};
+} // namespace WorkerResult
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.