Page MenuHomePhorge

D286.1771768147.diff
No OneTemporary

Size
33 KB
Referenced Files
None
Subscribers
None

D286.1771768147.diff

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -36,6 +36,7 @@
qt-job.cpp
${CMAKE_CURRENT_BINARY_DIR}/kazv-version.cpp
matrix-sdk.cpp
+ sso-login-process.cpp
kazv-session-lock-guard.cpp
matrix-room.cpp
matrix-room-list.cpp
@@ -106,6 +107,7 @@
Qt${QT_MAJOR_VERSION}::Concurrent
Qt${QT_MAJOR_VERSION}::Widgets
Qt${QT_MAJOR_VERSION}::Sql
+ Qt${QT_MAJOR_VERSION}::HttpServer
KF${KF_MAJOR_VERSION}::ConfigCore KF${KF_MAJOR_VERSION}::KIOCore
KF${KF_MAJOR_VERSION}::Notifications
KF${KF_MAJOR_VERSION}::CoreAddons
@@ -157,6 +159,7 @@
About.qml
PageManager.qml
LoginPage.qml
+ LoginFlows.qml
MainPage.qml
TabView.qml
Tab.qml
diff --git a/src/contents/ui/LoginFlows.qml b/src/contents/ui/LoginFlows.qml
new file mode 100644
--- /dev/null
+++ b/src/contents/ui/LoginFlows.qml
@@ -0,0 +1,211 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2020-2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import org.kde.kirigami as Kirigami
+import '.' as Kazv
+
+StackView {
+ id: loginFlows
+ initialItem: userIdScreen
+ implicitHeight: currentItem.childrenRect.height
+ property bool parentLoading
+ property bool isSwitchingAccount
+ property bool ownLoading: false
+ property bool loading: parentLoading || ownLoading
+ property string discoveredServerUrl: ''
+ property var availableFlows: []
+
+ property var userIdScreen: Kirigami.FormLayout {
+ width: parent.width
+ TextField {
+ id: userIdField
+ objectName: 'userIdField'
+ Layout.fillWidth: true
+ placeholderText: l10n.get('login-page-userid-input-placeholder')
+ Kirigami.FormData.label: l10n.get('login-page-userid-prompt')
+ enabled: !loginFlows.loading
+ }
+
+ TextField {
+ id: serverUrlField
+ objectName: 'serverUrlField'
+ Layout.fillWidth: true
+ placeholderText: l10n.get('login-page-server-url-placeholder')
+ Kirigami.FormData.label: l10n.get('login-page-server-url-prompt')
+ enabled: !loginFlows.loading
+ }
+
+ Button {
+ id: userIdNextButton
+ objectName: 'userIdNextButton'
+ text: l10n.get('login-page-next-action')
+ enabled: !loginFlows.loading
+ Layout.fillWidth: true
+ onClicked: () => {
+ loginFlows.ownLoading = true;
+ if (loginFlows.isSwitchingAccount) {
+ matrixSdk.startNewSession();
+ }
+ matrixSdk.discoverAndGetLoginFlows(userIdField.text, serverUrlField.text);
+ }
+ }
+ }
+
+ property var loginTypeScreen: ColumnLayout {
+ width: parent.width
+ id: loginTypeScreen
+ property var serverUrl: serverUrlField.text || loginFlows.discoveredServerUrl
+ property var ssoRedirectUrl: ''
+ Label {
+ Layout.fillWidth: true
+ text: l10n.get('login-page-server-label', { serverUrl: loginTypeScreen.serverUrl })
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: l10n.get('login-page-change-server-button')
+ onClicked: () => {
+ loginFlows.discoveredServerUrl = '';
+ loginFlows.ownLoading = false;
+ loginFlows.availableFlows = [];
+ loginFlows.pop();
+ }
+ }
+
+ Label {
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
+ text: l10n.get('login-page-login-flow-choice')
+ }
+
+ ColumnLayout {
+ RadioButton {
+ id: passwordRadio
+ objectName: 'passwordRadio'
+ Layout.fillWidth: true
+ text: l10n.get('login-page-login-flow-password')
+ enabled: loginFlows.supportsPassword()
+ }
+ RadioButton {
+ id: ssoRadio
+ objectName: 'ssoRadio'
+ Layout.fillWidth: true
+ text: l10n.get('login-page-login-flow-sso')
+ enabled: loginFlows.supportsSso()
+ }
+ }
+
+ Label {
+ objectName: 'noFlowIndicator'
+ Layout.fillWidth: true
+ visible: !loginFlows.supportsAnyLoginFlow()
+ text: l10n.get('login-page-no-flow-supported')
+ }
+
+ Label {
+ Layout.fillWidth: true
+ visible: passwordRadio.checked
+ wrapMode: Text.Wrap
+ text: l10n.get('login-page-password-prompt')
+ }
+
+ Kirigami.PasswordField {
+ id: passwordField
+ objectName: 'passwordField'
+ Layout.fillWidth: true
+ visible: passwordRadio.checked
+ enabled: !loginPage.loading
+ }
+
+ Button {
+ id: loginButton
+ objectName: 'passwordLoginButton'
+ Layout.fillWidth: true
+ text: l10n.get('login-page-login-button')
+ visible: passwordRadio.checked
+ enabled: !loginPage.loading
+ onClicked: {
+ loginFlows.ownLoading = true;
+ matrixSdk.login(userIdField.text, passwordField.text, loginTypeScreen.serverUrl);
+ }
+ }
+
+ Button {
+ id: ssoButton
+ objectName: 'ssoButton'
+ Layout.fillWidth: true
+ text: l10n.get('login-page-login-with-sso-button')
+ visible: ssoRadio.checked
+ enabled: !loginFlows.loading
+ onClicked: {
+ loginFlows.ownLoading = true;
+ loginTypeScreen.ssoRedirectUrl = matrixSdk.ssoLoginStart(loginTypeScreen.serverUrl);
+ if (!inTest) {
+ Qt.openUrlExternally(loginTypeScreen.ssoRedirectUrl);
+ }
+ }
+ }
+
+ Label {
+ visible: !!loginTypeScreen.ssoRedirectUrl
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
+ text: l10n.get('login-page-login-sso-redirect-url-label')
+ }
+
+ Kazv.SelectableText {
+ objectName: 'ssoRedirectUrlLabel'
+ visible: !!loginTypeScreen.ssoRedirectUrl
+ Layout.fillWidth: true
+ wrapMode: TextEdit.Wrap
+ text: loginTypeScreen.ssoRedirectUrl
+ }
+ }
+
+ property var conns: Connections {
+ target: matrixSdk
+ function onDiscoverFailed(errorCode, errorMsg) {
+ showPassiveNotification(l10n.get('login-page-discover-failed-enter-prompt', { errorCode, errorMsg }));
+ loginFlows.discoveredServerUrl = '';
+ loginFlows.ownLoading = false;
+ }
+
+ function onDiscoverSuccessful(serverUrl) {
+ loginFlows.discoveredServerUrl = serverUrl;
+ }
+
+ function onGetLoginFlowsFailed(errorCode, errorMsg) {
+ showPassiveNotification(l10n.get('login-page-get-login-flows-failed-prompt', { errorCode, errorMsg }));
+ loginFlows.ownLoading = false;
+ loginFlows.availableFlows = [];
+ }
+
+ function onGetLoginFlowsSuccessful(flows) {
+ loginFlows.availableFlows = flows;
+ loginFlows.push(loginTypeScreen);
+ loginFlows.ownLoading = false;
+ }
+
+ function onLoginFailed() {
+ loginFlows.ownLoading = false;
+ }
+ }
+
+ function supportsPassword() {
+ return availableFlows.some((flow) => flow.type === 'm.login.password');
+ }
+
+ function supportsSso() {
+ return availableFlows.some((flow) => flow.type === 'm.login.sso');
+ }
+
+ function supportsAnyLoginFlow() {
+ return supportsPassword() || supportsSso();
+ }
+}
diff --git a/src/contents/ui/LoginPage.qml b/src/contents/ui/LoginPage.qml
--- a/src/contents/ui/LoginPage.qml
+++ b/src/contents/ui/LoginPage.qml
@@ -1,6 +1,6 @@
/*
* This file is part of kazv.
- * SPDX-FileCopyrightText: 2020-2021 Tusooa Zhu <tusooa@kazv.moe>
+ * SPDX-FileCopyrightText: 2020-2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -8,12 +8,14 @@
import QtQuick.Layouts 1.1
import QtQuick.Controls 2.15
import org.kde.kirigami 2.8 as Kirigami
+import '.' as Kazv
Kirigami.Page {
id: loginPage
title: l10n.get('login-page-title')
property var isSwitchingAccount: false
property bool loading: loadingSession
+ property bool inTest: false
ColumnLayout {
width: parent.width
@@ -27,6 +29,8 @@
Label {
text: l10n.get('login-page-existing-sessions-prompt')
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
}
ComboBox {
@@ -50,69 +54,19 @@
}
Label {
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
text: l10n.get('login-page-alternative-password-login-prompt')
}
visible: sessions.length > 0
}
- Kirigami.FormLayout {
- TextField {
- id: userIdField
- objectName: 'userIdField'
- Layout.fillWidth: true
- placeholderText: l10n.get('login-page-userid-input-placeholder')
- Kirigami.FormData.label: l10n.get('login-page-userid-prompt')
- enabled: !loginPage.loading
- }
-
- Kirigami.PasswordField {
- id: passwordField
- objectName: 'passwordField'
- Layout.fillWidth: true
- Kirigami.FormData.label: l10n.get('login-page-password-prompt')
- enabled: !loginPage.loading
- }
-
- TextField {
- id: serverUrlField
- objectName: 'serverUrlField'
- Layout.fillWidth: true
- placeholderText: l10n.get('login-page-server-url-placeholder')
- Kirigami.FormData.label: l10n.get('login-page-server-url-prompt')
- enabled: !loginPage.loading
- }
-
- RowLayout {
- Layout.fillWidth: true
-
- property var loginButton: Button {
- id: loginButton
- objectName: 'loginButton'
- text: l10n.get('login-page-login-button')
- Layout.fillWidth: true
- onClicked: {
- console.log('Trying to login...')
- if (isSwitchingAccount) {
- matrixSdk.startNewSession();
- }
- matrixSdk.login(userIdField.text, passwordField.text, serverUrlField.text);
- console.log('Login job sent')
- }
- enabled: !loginPage.loading
- }
-
- property var closeButton: Button {
- id: closeButton
- text: l10n.get('login-page-close-button')
- Layout.fillWidth: true
- onClicked: {
- pageStack.removePage(loginPage);
- }
- enabled: !loginPage.loading
- }
-
- data: isSwitchingAccount ? [closeButton, loginButton] : [loginButton]
- }
+ Kazv.LoginFlows {
+ objectName: 'loginFlows'
+ Layout.preferredWidth: parent.width
+ Layout.maximumWidth: parent.width
+ parentLoading: loginPage.loading
+ isSwitchingAccount: loginPage.isSwitchingAccount
}
ProgressBar {
diff --git a/src/contents/ui/Main.qml b/src/contents/ui/Main.qml
--- a/src/contents/ui/Main.qml
+++ b/src/contents/ui/Main.qml
@@ -26,6 +26,7 @@
property var matrixSdk: MK.MatrixSdk {
onLoginSuccessful: {
+ console.log('Login successful');
switchToMainPage();
recordLastSession();
root.purgeTimer.start();
@@ -34,10 +35,6 @@
console.log("Login Failed");
showPassiveNotification(l10n.get('login-page-request-failed-prompt', { errorCode, errorMsg }));
}
- onDiscoverFailed: {
- console.log("Discover Failed");
- showPassiveNotification(l10n.get('login-page-discover-failed-enter-prompt', { errorCode, errorMsg }));
- }
onSessionChanged: {
console.log('session changed');
reloadSdkVariables();
diff --git a/src/l10n/cmn-Hans/100-ui.ftl b/src/l10n/cmn-Hans/100-ui.ftl
--- a/src/l10n/cmn-Hans/100-ui.ftl
+++ b/src/l10n/cmn-Hans/100-ui.ftl
@@ -55,6 +55,15 @@
login-page-discover-failed-enter-prompt = 不能检测此用户所在的服务器,或者服务器不可用。错误代码:{ $errorCode }。错误讯息:{ $errorMsg }。请手动输入服务器链接。
login-page-server-url-placeholder = 例如: https://example.org
login-page-server-url-prompt = 服务器链接(可选):
+login-page-next-action = 下一步
+login-page-server-label = 连接到 { $serverUrl }
+login-page-login-flow-choice = 选择一种登录方式:
+login-page-login-flow-password = 密码
+login-page-login-flow-sso = 单点登录
+login-page-login-with-sso-button = 进行单点登录
+login-page-login-sso-redirect-url-label = 你应该看到浏览器打开了一个网页。遵从那个页面上的指示。如果没看到,那就手动打开下面的 URL:
+login-page-change-server-button = 切换服务器
+login-page-no-flow-supported = 这个服务器并不提供任何 { -kt-app-name } 支持的登录流程。
session-load-failure-not-found = 找不到会话 { $sessionName }。
session-load-failure-format-unknown = 会话 { $sessionName } 包含不支持的格式。是由未来版本的 { -kt-app-name } 保存的吗?
diff --git a/src/l10n/en/100-ui.ftl b/src/l10n/en/100-ui.ftl
--- a/src/l10n/en/100-ui.ftl
+++ b/src/l10n/en/100-ui.ftl
@@ -55,6 +55,15 @@
login-page-discover-failed-enter-prompt = Unable to detect the server this user is on, or the server is unavailable. Error code: { $errorCode }. Error message: { $errorMsg }. Please enter the server url manually.
login-page-server-url-placeholder = E.g.: https://example.org
login-page-server-url-prompt = Server url (optional):
+login-page-next-action = Next
+login-page-server-label = Connecting to { $serverUrl }
+login-page-login-flow-choice = Choose a way to log in:
+login-page-login-flow-password = Password
+login-page-login-flow-sso = Single Sign On
+login-page-login-with-sso-button = Log in using Single Sign On
+login-page-login-sso-redirect-url-label = You should see a web page open in your browser. Follow the instructions on that page. If not, manually open the following URL:
+login-page-change-server-button = Change server
+login-page-no-flow-supported = This server does not have any login flow that { -kt-app-name } supports.
session-load-failure-not-found = The session { $sessionName } is not found.
session-load-failure-format-unknown = The session { $sessionName } contains an unsupported format. Is it saved using a future version of { -kt-app-name }?
diff --git a/src/matrix-sdk.hpp b/src/matrix-sdk.hpp
--- a/src/matrix-sdk.hpp
+++ b/src/matrix-sdk.hpp
@@ -130,6 +130,10 @@
void loginSuccessful(Kazv::KazvEvent e);
void loginFailed(QString errorCode, QString errorMsg);
void discoverFailed(QString errorCode, QString errorMsg);
+ void discoverSuccessful(QString serverUrl);
+ void getLoginFlowsFailed(QString errorCode, QString errorMsg);
+ void getLoginFlowsSuccessful(QJsonValue flows);
+ void ssoLoginTokenAvailable();
void logoutSuccessful();
void logoutFailed(QString errorCode, QString errorMsg);
@@ -142,6 +146,29 @@
public Q_SLOTS:
void login(const QString &userId, const QString &password, const QString &homeserverUrl);
+
+ /**
+ * Auto-discover the server url and then get the login flows from the server.
+ *
+ * If homeserverUrl is not provided, try to get it from auto-discovery.
+ * If discovery is successful, or it is already provided, get the login flows
+ * by calling getLoginFlows.
+ */
+ void discoverAndGetLoginFlows(const QString &userId, const QString &homeserverUrl);
+ void getLoginFlows(const QString &serverUrl);
+
+ /**
+ * Start SSO login flow.
+ *
+ * It will start an http server on a local port and pass it as
+ * the redirect url to the SSO login link.
+ *
+ * When the user completes the SSO login flow, we get the login token
+ * for us to login via the token flow.
+ *
+ * @return The SSO login link for the user to open.
+ */
+ QUrl ssoLoginStart(const QString &homeserverUrl);
void logout();
/**
diff --git a/src/matrix-sdk.cpp b/src/matrix-sdk.cpp
--- a/src/matrix-sdk.cpp
+++ b/src/matrix-sdk.cpp
@@ -25,7 +25,7 @@
#include <crypto/base64.hpp>
#include <csapi/directory.hpp>
#include <client/alias.hpp>
-
+#include <csapi/login.hpp>
#include <zug/util.hpp>
#include <lager/event_loop/qt.hpp>
@@ -48,6 +48,7 @@
#include "matrix-utils.hpp"
#include "kazv-session-lock-guard.hpp"
#include "db-store.hpp"
+#include "sso-login-process.hpp"
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
@@ -150,6 +151,7 @@
LagerStoreEventEmitter::Watchable watchable;
SdkT sdk;
QTimer saveTimer;
+ QPointer<SsoLoginProcess> ssoLoginProcess{};
using SecondaryRootT = decltype(sdk.createSecondaryRoot(std::declval<lager::with_qt_event_loop>()));
SecondaryRootT secondaryRoot;
@@ -219,6 +221,10 @@
void cleanup()
{
qCInfo(kazvLog) << "start to clean up everything";
+ if (oldD->ssoLoginProcess) {
+ oldD->ssoLoginProcess->deleteLater();
+ }
+
oldD->clientOnSecondaryRoot.stopSyncing()
.then([obj=oldD->obj, thread=oldD->thread](auto &&) {
qCDebug(kazvLog) << "stopped syncing";
@@ -644,6 +650,76 @@
m_d->runIoContext();
}
+
+void MatrixSdk::discoverAndGetLoginFlows(const QString &userId, const QString &homeserverUrl)
+{
+ auto validated = validateHomeserverUrl(homeserverUrl);
+ if (!validated.empty()) {
+ getLoginFlows(homeserverUrl);
+ } else {
+ m_d->clientOnSecondaryRoot
+ .autoDiscover(userId.toStdString())
+ .then([
+ this
+ ](const EffectStatus &res) {
+ if (!res.success()) {
+ Q_EMIT discoverFailed(
+ QString::fromStdString(res.dataStr("errorCode")),
+ QString::fromStdString(res.dataStr("error"))
+ );
+ return;
+ }
+ auto serverUrl = QString::fromStdString(res.dataStr("homeserverUrl"));
+ Q_EMIT discoverSuccessful(serverUrl);
+ getLoginFlows(serverUrl);
+ });
+ }
+
+ m_d->runIoContext();
+}
+
+void MatrixSdk::getLoginFlows(const QString &serverUrl)
+{
+ m_d->jobHandler->submit(
+ Api::GetLoginFlowsJob(serverUrl.toStdString()),
+ [this](Api::GetLoginFlowsResponse r) {
+ if (!r.success()) {
+ Q_EMIT getLoginFlowsFailed(
+ QString::fromStdString(r.errorCode()),
+ QString::fromStdString(r.errorMessage())
+ );
+ return;
+ }
+ auto v = std::move(r).jsonBody().get().at("flows").template get<QJsonValue>();
+ Q_EMIT getLoginFlowsSuccessful(v);
+ }
+ );
+}
+
+QUrl MatrixSdk::ssoLoginStart(const QString &homeserverUrl)
+{
+ if (m_d->ssoLoginProcess) {
+ m_d->ssoLoginProcess->deleteLater();
+ }
+ m_d->ssoLoginProcess = new SsoLoginProcess();
+ if (!m_d->ssoLoginProcess->startServer()) {
+ return QUrl();
+ }
+ auto link = m_d->ssoLoginProcess->getSsoLink(homeserverUrl);
+ connect(m_d->ssoLoginProcess.get(), &SsoLoginProcess::loginTokenAvailable,
+ this, [this, homeserverUrl](const QString &loginToken) {
+ Q_EMIT ssoLoginTokenAvailable();
+ qCInfo(kazvLog) << "Got SSO login token";
+ m_d->ssoLoginProcess->deleteLater();
+ m_d->clientOnSecondaryRoot.mLoginTokenLogin(
+ homeserverUrl.toStdString(),
+ loginToken.toStdString(),
+ clientName
+ );
+ });
+ return link;
+}
+
void MatrixSdk::logout()
{
m_d->clientOnSecondaryRoot.logout()
diff --git a/src/sso-login-process.hpp b/src/sso-login-process.hpp
new file mode 100644
--- /dev/null
+++ b/src/sso-login-process.hpp
@@ -0,0 +1,29 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <kazv-defs.hpp>
+#include <QObject>
+#include <QUrl>
+#include <memory>
+
+class SsoLoginProcess : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit SsoLoginProcess(QObject *parent = 0);
+ ~SsoLoginProcess() override;
+
+ bool startServer();
+ QUrl getSsoLink(QString homeserverUrl) const;
+
+Q_SIGNALS:
+ void loginTokenAvailable(QString loginToken);
+
+private:
+ struct Private;
+ std::unique_ptr<Private> m_d;
+};
diff --git a/src/sso-login-process.cpp b/src/sso-login-process.cpp
new file mode 100644
--- /dev/null
+++ b/src/sso-login-process.cpp
@@ -0,0 +1,102 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include "sso-login-process.hpp"
+#include "kazv-log.hpp"
+#include <csapi/sso_login_redirect.hpp>
+#include <QHttpServer>
+#include <QHttpServerRequest>
+#include <QTcpServer>
+#include <QPointer>
+#include <QUrl>
+#include <QUrlQuery>
+#include <QRandomGenerator>
+#include <QByteArray>
+
+using namespace Kazv;
+using namespace Qt::Literals::StringLiterals;
+
+struct SsoLoginProcess::Private
+{
+ QPointer<QHttpServer> httpServer;
+ QPointer<QTcpServer> tcpServer;
+ QString recordedToken;
+ QString suffix;
+
+ Private()
+ : httpServer(new QHttpServer)
+ , tcpServer(new QTcpServer)
+ , recordedToken()
+ , suffix()
+ {}
+
+ ~Private()
+ {
+ if (tcpServer) {
+ tcpServer->close();
+ tcpServer->deleteLater();
+ }
+ if (httpServer) {
+ httpServer->deleteLater();
+ }
+ }
+};
+
+SsoLoginProcess::SsoLoginProcess(QObject *parent)
+ : QObject(parent)
+ , m_d(std::make_unique<Private>())
+{
+}
+
+SsoLoginProcess::~SsoLoginProcess() = default;
+
+bool SsoLoginProcess::startServer()
+{
+ // Generate a random suffix so that we can be reasonably sure that
+ // it is the user who reaches our local redirect callback
+ // This is recommended in spec
+ // https://spec.matrix.org/v1.17/client-server-api/#security-considerations-14
+ auto rg = QRandomGenerator::securelySeeded();
+ QByteArray randomRange(32, '\0');
+ rg.generate(randomRange.begin(), randomRange.end());
+ m_d->suffix = QString::fromUtf8(randomRange.toHex());
+ m_d->httpServer->route(u"/sso-redirect/<arg>"_s, QHttpServerRequest::Method::Get, this, [this](const QString &gotSuffix, const QHttpServerRequest &request) {
+ if (gotSuffix != m_d->suffix) {
+ return QHttpServerResponse(u"Wrong suffix"_s, QHttpServerResponse::StatusCode::Forbidden);
+ }
+ if (!m_d->recordedToken.isEmpty()) {
+ return QHttpServerResponse(u"Already taken"_s, QHttpServerResponse::StatusCode::Forbidden);
+ }
+ auto vs = request.query().allQueryItemValues(u"loginToken"_s);
+ if (vs.size() != 1) {
+ return QHttpServerResponse(u"No loginToken"_s, QHttpServerResponse::StatusCode::BadRequest);
+ }
+ m_d->recordedToken = vs[0];
+ Q_EMIT loginTokenAvailable(m_d->recordedToken);
+ return QHttpServerResponse(u"Ok"_s);
+ });
+
+ if (!m_d->tcpServer->listen(QHostAddress(QHostAddress::LocalHost))) {
+ return false;
+ }
+ if (!m_d->httpServer->bind(m_d->tcpServer)) {
+ return false;
+ }
+ return true;
+}
+
+QUrl SsoLoginProcess::getSsoLink(QString homeserverUrl) const
+{
+ auto port = m_d->tcpServer->serverPort();
+ Api::RedirectToSSOJob j(homeserverUrl.toStdString(), "http://127.0.0.1:" + std::to_string(port) + "/sso-redirect/" + m_d->suffix.toStdString());
+ auto u = QUrl(QString::fromStdString(j.url()));
+ auto q = QUrlQuery();
+ for (auto [k, v] : j.requestQuery()) {
+ q.addQueryItem(QString::fromStdString(k), QString::fromStdString(v));
+ }
+ u.setQuery(q);
+ return u;
+}
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -34,6 +34,7 @@
matrix-event-test.cpp
kazv-file-test.cpp
db-store-test.cpp
+ sso-login-process-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvtestlib
)
diff --git a/src/tests/quick-tests/test-helpers/MatrixSdkMock.qml b/src/tests/quick-tests/test-helpers/MatrixSdkMock.qml
--- a/src/tests/quick-tests/test-helpers/MatrixSdkMock.qml
+++ b/src/tests/quick-tests/test-helpers/MatrixSdkMock.qml
@@ -26,6 +26,8 @@
property var joinRoom: mockHelper.promise()
property var sendAccountData: mockHelper.promise()
property var login: mockHelper.noop()
+ property var ssoLoginStart: mockHelper.func((serverUrl) => serverUrl + '/_matrix/client/v3/login/sso/redirect?redirectUrl=http://127.0.0.1:7456/sso-redirect/random')
+ property var discoverAndGetLoginFlows: mockHelper.noop()
property var addDirectRoom: mockHelper.promise([
'userId',
'roomId'
@@ -42,6 +44,12 @@
property var specVersions: ["v1.11"]
+ signal discoverFailed(string errorCode, string errorMsg)
+ signal discoverSuccessful(string serverUrl)
+ signal getLoginFlowsFailed(string errorCode, string errorMsg)
+ signal getLoginFlowsSuccessful(var flows)
+ signal loginFailed(string errorCode, string errorMsg)
+
function allSessions() {
return sessions;
}
diff --git a/src/tests/quick-tests/tst_LoginPage.qml b/src/tests/quick-tests/tst_LoginPage.qml
--- a/src/tests/quick-tests/tst_LoginPage.qml
+++ b/src/tests/quick-tests/tst_LoginPage.qml
@@ -12,8 +12,22 @@
QmlHelpers.TestItem {
id: item
+ property bool loadingSession: false
+
Kazv.LoginPage {
id: loginPage
+ anchors.fill: parent
+ inTest: true
+ }
+
+ function allFlows() {
+ return [{
+ type: 'm.login.sso',
+ }, {
+ type: 'm.login.token',
+ }, {
+ type: 'm.login.password',
+ }];
}
TestCase {
@@ -23,22 +37,87 @@
function init() {
item.mockHelper.clearAll();
+ const loginFlows = findChild(loginPage, 'loginFlows');
+ loginFlows.ownLoading = false;
+ loginFlows.discoveredServerUrl = '';
+ loginFlows.availableFlows = [];
+ if (loginFlows.currentItem !== loginFlows.userIdScreen) {
+ loginFlows.pop();
+ }
+ waitForRendering(loginFlows);
findChild(loginPage, 'userIdField').text = '@foo:example.org';
- findChild(loginPage, 'passwordField').text = 'somepassword';
}
- function test_discover() {
+ function test_noDiscover() {
+ const loginFlows = findChild(loginPage, 'loginFlows');
+ findChild(loginPage, 'serverUrlField').text = 'https://mxs.example.org';
+ waitForRendering(loginFlows);
+ wait(500);
+ mouseClick(findChild(loginPage, 'userIdNextButton'));
+ tryVerify(() => item.matrixSdk.discoverAndGetLoginFlows.calledTimes() === 1);
+ compare(item.matrixSdk.discoverAndGetLoginFlows.lastArgs()[1], 'https://mxs.example.org');
+ }
+
+ function test_ssoLoginFlow() {
+ wait(500);
+ const loginFlows = findChild(loginPage, 'loginFlows');
findChild(loginPage, 'serverUrlField').text = '';
- mouseClick(findChild(loginPage, 'loginButton'));
- compare(item.matrixSdk.login.calledTimes(), 1);
- compare(item.matrixSdk.login.lastArgs()[2], '');
+ mouseClick(findChild(loginPage, 'userIdNextButton'));
+ tryVerify(() => item.matrixSdk.discoverAndGetLoginFlows.calledTimes() === 1);
+ compare(item.matrixSdk.discoverAndGetLoginFlows.lastArgs()[1], '');
+ item.matrixSdk.discoverSuccessful('https://mxs.example.org');
+ tryVerify(() => loginFlows.discoveredServerUrl === 'https://mxs.example.org');
+ item.matrixSdk.getLoginFlowsSuccessful(allFlows());
+ tryVerify(() => findChild(loginPage, 'passwordRadio').enabled);
+ tryVerify(() => findChild(loginPage, 'ssoRadio').enabled);
+ tryVerify(() => findChild(loginPage, 'ssoRadio').visible);
+ waitForRendering(loginFlows);
+ mouseClick(findChild(loginPage, 'ssoRadio'));
+ tryVerify(() => findChild(loginPage, 'ssoButton').visible);
+ waitForRendering(loginFlows);
+ wait(500);
+ mouseClick(findChild(loginPage, 'ssoButton'));
+ tryVerify(() => findChild(loginPage, 'ssoRedirectUrlLabel').visible);
+ tryVerify(() => findChild(loginPage, 'ssoRedirectUrlLabel').text.startsWith('https://mxs.example.org/'));
}
- function test_noDiscover() {
- findChild(loginPage, 'serverUrlField').text = 'https://mxs.example.org';
- mouseClick(findChild(loginPage, 'loginButton'));
- compare(item.matrixSdk.login.calledTimes(), 1);
- compare(item.matrixSdk.login.lastArgs()[2], 'https://mxs.example.org');
+ function test_passwordLoginFlow() {
+ wait(500);
+ const loginFlows = findChild(loginPage, 'loginFlows');
+ findChild(loginPage, 'serverUrlField').text = '';
+ mouseClick(findChild(loginPage, 'userIdNextButton'));
+ tryVerify(() => item.matrixSdk.discoverAndGetLoginFlows.calledTimes() === 1);
+ compare(item.matrixSdk.discoverAndGetLoginFlows.lastArgs()[1], '');
+ item.matrixSdk.discoverSuccessful('https://mxs.example.org');
+ tryVerify(() => loginFlows.discoveredServerUrl === 'https://mxs.example.org');
+ item.matrixSdk.getLoginFlowsSuccessful(allFlows());
+ tryVerify(() => findChild(loginPage, 'passwordRadio').enabled);
+ tryVerify(() => findChild(loginPage, 'passwordRadio').visible);
+ waitForRendering(loginFlows);
+ wait(500);
+ mouseClick(findChild(loginPage, 'passwordRadio'));
+ tryVerify(() => findChild(loginPage, 'passwordField').visible);
+ findChild(loginPage, 'passwordField').text = 'somePassword';
+ tryVerify(() => findChild(loginPage, 'passwordLoginButton').visible);
+ waitForRendering(loginFlows);
+ wait(500);
+ mouseClick(findChild(loginPage, 'passwordLoginButton'));
+ tryVerify(() => item.matrixSdk.login.calledTimes() === 1);
+ }
+
+ function test_noFlowSupported() {
+ wait(500);
+ const loginFlows = findChild(loginPage, 'loginFlows');
+ findChild(loginPage, 'serverUrlField').text = '';
+ mouseClick(findChild(loginPage, 'userIdNextButton'));
+ tryVerify(() => item.matrixSdk.discoverAndGetLoginFlows.calledTimes() === 1);
+ compare(item.matrixSdk.discoverAndGetLoginFlows.lastArgs()[1], '');
+ item.matrixSdk.discoverSuccessful('https://mxs.example.org');
+ tryVerify(() => loginFlows.discoveredServerUrl === 'https://mxs.example.org');
+ item.matrixSdk.getLoginFlowsSuccessful([{ type: 'm.login.xxx' }]);
+ tryVerify(() => !findChild(loginPage, 'passwordRadio').enabled);
+ tryVerify(() => !findChild(loginPage, 'ssoRadio').enabled);
+ tryVerify(() => findChild(loginPage, 'noFlowIndicator').visible);
}
}
}
diff --git a/src/tests/sso-login-process-test.cpp b/src/tests/sso-login-process-test.cpp
new file mode 100644
--- /dev/null
+++ b/src/tests/sso-login-process-test.cpp
@@ -0,0 +1,124 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <sso-login-process.hpp>
+#include <qt-job-handler.hpp>
+#include <QObject>
+#include <QtTest>
+#include <QUrlQuery>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QSignalSpy>
+
+using namespace Qt::Literals::StringLiterals;
+
+class SsoLoginProcessTest : public QObject
+{
+ Q_OBJECT
+ QPointer<SsoLoginProcess> p;
+ QNetworkAccessManager nam;
+
+private:
+ std::pair<int, QString> getResult(QNetworkReply *r);
+
+private Q_SLOTS:
+ void init();
+ void cleanup();
+ void testLogin();
+ void testErrors();
+};
+
+void SsoLoginProcessTest::init()
+{
+ p = new SsoLoginProcess();
+}
+
+void SsoLoginProcessTest::cleanup()
+{
+ p->deleteLater();
+}
+
+std::pair<int, QString> SsoLoginProcessTest::getResult(QNetworkReply *r)
+{
+ QSignalSpy spy(r, &QNetworkReply::finished);
+ spy.wait();
+ auto res = QString::fromUtf8(r->readAll());
+ auto statusCode = r->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ r->deleteLater();
+ return {statusCode, res};
+}
+
+void SsoLoginProcessTest::testLogin()
+{
+ p->startServer();
+ auto link = p->getSsoLink(u"https://example.com"_s);
+ QCOMPARE(link.host(), u"example.com"_s);
+ QUrlQuery q(link.query());
+ auto vs = q.allQueryItemValues(u"redirectUrl"_s);
+ QCOMPARE(vs.size(), 1);
+ QVERIFY(vs[0].startsWith(u"http://127.0.0.1:"_s));
+ auto localUrl = QUrl(vs[0]);
+ QUrlQuery localQuery;
+ localQuery.addQueryItem(u"loginToken"_s, u"mew"_s);
+ localUrl.setQuery(localQuery);
+ QSignalSpy spy(p, &SsoLoginProcess::loginTokenAvailable);
+ auto reply = nam.get(QNetworkRequest(localUrl));
+ auto [statusCode, body] = getResult(reply);
+ QCOMPARE(statusCode, 200);
+ QCOMPARE(spy.size(), 1);
+ QCOMPARE(spy[0].at(0).toString(), u"mew"_s);
+}
+
+void SsoLoginProcessTest::testErrors()
+{
+ p->startServer();
+ auto link = p->getSsoLink(u"https://example.com"_s);
+ auto localUrlStr = QUrlQuery(link.query()).allQueryItemValues(u"redirectUrl"_s)[0];
+ QUrlQuery localQuery;
+ localQuery.addQueryItem(u"loginToken"_s, u"mew"_s);
+ QSignalSpy spy(p, &SsoLoginProcess::loginTokenAvailable);
+ // no login token
+ {
+ auto localUrl = QUrl(localUrlStr);
+ auto reply = nam.get(QNetworkRequest(localUrl));
+ auto [statusCode, body] = getResult(reply);
+ QCOMPARE(statusCode, 400);
+ }
+ // wrong suffix
+ {
+ auto wrongSuffixStr = localUrlStr;
+ wrongSuffixStr.erase(wrongSuffixStr.end() - 2, wrongSuffixStr.end());
+ auto localUrl = QUrl(wrongSuffixStr);
+ localUrl.setQuery(localQuery);
+ auto reply = nam.get(QNetworkRequest(localUrl));
+ auto [statusCode, body] = getResult(reply);
+ QCOMPARE(statusCode, 403);
+ }
+ // ok
+ {
+ auto localUrl = QUrl(localUrlStr);
+ localUrl.setQuery(localQuery);
+ auto reply = nam.get(QNetworkRequest(localUrl));
+ auto [statusCode, body] = getResult(reply);
+ QCOMPARE(statusCode, 200);
+ }
+ // already taken
+ {
+ auto localUrl = QUrl(localUrlStr);
+ QUrlQuery anotherQuery;
+ anotherQuery.addQueryItem(u"loginToken"_s, u"another"_s);
+ localUrl.setQuery(anotherQuery);
+ auto reply = nam.get(QNetworkRequest(localUrl));
+ auto [statusCode, body] = getResult(reply);
+ QCOMPARE(statusCode, 403);
+ }
+ QCOMPARE(spy.size(), 1);
+ QCOMPARE(spy[0].at(0).toString(), u"mew"_s);
+}
+
+QTEST_MAIN(SsoLoginProcessTest)
+
+#include "sso-login-process-test.moc"

File Metadata

Mime Type
text/plain
Expires
Sun, Feb 22, 5:49 AM (14 h, 34 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1122536
Default Alt Text
D286.1771768147.diff (33 KB)

Event Timeline