diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fe3e49cf4..44a55010a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -114,6 +114,7 @@ set(Mapper_Common_SRCS collaboration/managed_map_workspace.cpp collaboration/map_hub_api_client.cpp collaboration/map_hub_credentials.cpp + collaboration/map_hub_device_authorization.cpp collaboration/map_hub_imagery_catalog.cpp core/autosave.cpp diff --git a/src/collaboration/map_hub_api_client.cpp b/src/collaboration/map_hub_api_client.cpp index dbf2a42c6..fc26a4b76 100644 --- a/src/collaboration/map_hub_api_client.cpp +++ b/src/collaboration/map_hub_api_client.cpp @@ -290,6 +290,37 @@ void MapHubApiClient::health(JsonHandler handler) { std::move(handler)); } +void MapHubApiClient::startMapperConnection(const QString &client_name, + JsonHandler handler) { + if (!ensureReady(false, handler)) + return; + if (client_name.trimmed().isEmpty() || client_name.toUtf8().size() > 80) { + handler({}, {0, QStringLiteral("invalid_client_name"), + tr("Mapper could not create a valid connection name.")}); + return; + } + sendJson("POST", QStringLiteral("/api/v1/auth/mapper/connect"), + QJsonObject{{QStringLiteral("client_name"), client_name.trimmed()}}, + false, std::move(handler)); +} + +void MapHubApiClient::exchangeMapperConnection(const QString &request_id, + const QString &device_secret, + JsonHandler handler) { + if (!ensureReady(false, handler)) + return; + if (!validStableId(request_id) || !validHeaderValue(device_secret, 200)) { + handler({}, {0, QStringLiteral("invalid_connection"), + tr("Mapper's pending connection is invalid.")}); + return; + } + sendJson("POST", + QStringLiteral("/api/v1/auth/mapper/connect/%1/exchange") + .arg(request_id), + QJsonObject{{QStringLiteral("device_secret"), device_secret}}, + false, std::move(handler)); +} + void MapHubApiClient::library(JsonHandler handler) { if (!ensureReady(true, handler)) return; diff --git a/src/collaboration/map_hub_api_client.h b/src/collaboration/map_hub_api_client.h index f9dd8d142..772e560ac 100644 --- a/src/collaboration/map_hub_api_client.h +++ b/src/collaboration/map_hub_api_client.h @@ -48,6 +48,10 @@ class MapHubApiClient : public QObject { QString configurationError() const; void health(JsonHandler handler); + void startMapperConnection(const QString &client_name, JsonHandler handler); + void exchangeMapperConnection(const QString &request_id, + const QString &device_secret, + JsonHandler handler); void library(JsonHandler handler); void projectManifest(const QString &project_id, JsonHandler handler); void createProject(const QJsonObject &project, const QString &idempotency_key, diff --git a/src/collaboration/map_hub_device_authorization.cpp b/src/collaboration/map_hub_device_authorization.cpp new file mode 100644 index 000000000..6cf1918ea --- /dev/null +++ b/src/collaboration/map_hub_device_authorization.cpp @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Ethan O'Connor + * + * This file is part of OpenOrienteering. + */ + +#include "map_hub_device_authorization.h" + +#include +#include + +namespace OpenOrienteering { + +namespace { + +constexpr auto min_poll_seconds = 1; +constexpr auto max_poll_seconds = 10; + +bool sameOrigin(const QUrl &first, const QUrl &second) { + return first.scheme().compare(second.scheme(), Qt::CaseInsensitive) == 0 && + first.host().compare(second.host(), Qt::CaseInsensitive) == 0 && + first.port(first.scheme() == QLatin1String("https") ? 443 : 80) == + second.port(second.scheme() == QLatin1String("https") ? 443 : 80); +} + +MapHubApiClient::Error invalidResponse(QString message) { + return {0, QStringLiteral("invalid_response"), std::move(message)}; +} + +} // namespace + +MapHubDeviceAuthorization::MapHubDeviceAuthorization(QString server_url, + QString client_name, + QObject *parent) + : QObject(parent), + client(new MapHubApiClient(std::move(server_url), {}, this)), + poll_timer(new QTimer(this)), client_name(std::move(client_name)) { + poll_timer->setSingleShot(false); + connect(poll_timer, &QTimer::timeout, this, + &MapHubDeviceAuthorization::poll); +} + +void MapHubDeviceAuthorization::start() { + if (running) + return; + running = true; + client->startMapperConnection( + client_name, + [this](const QJsonObject &response, const MapHubApiClient::Error &error) { + if (error) { + finish({}, error); + return; + } + request_id = response.value(QStringLiteral("request_id")).toString(); + device_secret = + response.value(QStringLiteral("device_secret")).toString(); + auto verification_url = + QUrl(response.value(QStringLiteral("verification_url")).toString()); + auto interval = response.value(QStringLiteral("interval")).toInt(); + if (QUuid(request_id).isNull() || device_secret.isEmpty() || + device_secret.toUtf8().size() > 200 || !verification_url.isValid() || + verification_url.userInfo().size() || + !sameOrigin(client->serverUrl(), verification_url) || interval < 1) { + finish({}, invalidResponse(tr("Map Hub returned an invalid sign-in response."))); + return; + } + interval = qBound(min_poll_seconds, interval, max_poll_seconds); + emit verificationRequired(verification_url, + response.value(QStringLiteral("user_code")) + .toString()); + poll_timer->start(interval * 1000); + QTimer::singleShot( + qBound(10, response.value(QStringLiteral("expires_in")).toInt(), + 600) * 1000, + this, [this] { + if (running) + finish({}, {0, QStringLiteral("connection_expired"), + tr("Map Hub sign-in expired before it was approved.")}); + }); + }); +} + +void MapHubDeviceAuthorization::cancel() { + if (running) + finish({}, {0, QStringLiteral("cancelled"), tr("Map Hub sign-in was cancelled.")}); +} + +void MapHubDeviceAuthorization::poll() { + if (!running) + return; + client->exchangeMapperConnection( + request_id, device_secret, + [this](const QJsonObject &response, const MapHubApiClient::Error &error) { + if (error) { + finish({}, error); + return; + } + const auto status = response.value(QStringLiteral("status")).toString(); + if (status == QLatin1String("pending")) + return; + if (status != QLatin1String("connected")) { + finish({}, invalidResponse(tr("Map Hub returned an invalid sign-in status."))); + return; + } + Result result; + result.token = response.value(QStringLiteral("token")).toString(); + result.organization_name = + response.value(QStringLiteral("organization")) + .toObject() + .value(QStringLiteral("name")) + .toString(); + if (result.token.isEmpty() || result.token.toUtf8().size() > 4096) { + finish({}, invalidResponse(tr("Map Hub returned an invalid account credential."))); + return; + } + finish(result, {}); + }); +} + +void MapHubDeviceAuthorization::finish(const Result &result, + const MapHubApiClient::Error &error) { + if (!running) + return; + running = false; + poll_timer->stop(); + request_id.clear(); + device_secret.clear(); + emit completed(result, error); +} + +} // namespace OpenOrienteering diff --git a/src/collaboration/map_hub_device_authorization.h b/src/collaboration/map_hub_device_authorization.h new file mode 100644 index 000000000..48db60fac --- /dev/null +++ b/src/collaboration/map_hub_device_authorization.h @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Ethan O'Connor + * + * This file is part of OpenOrienteering. + */ + +#ifndef OPENORIENTEERING_MAP_HUB_DEVICE_AUTHORIZATION_H +#define OPENORIENTEERING_MAP_HUB_DEVICE_AUTHORIZATION_H + +#include +#include +#include + +#include "collaboration/map_hub_api_client.h" + +class QTimer; + +namespace OpenOrienteering { + +/** + * Drives the browser-mediated Map Hub sign-in flow for one Mapper instance. + * + * The browser authenticates the person (normally with a passkey); this class + * holds the device secret, polls only the exact server origin, and returns the + * one-time bearer credential to its caller for secure storage. + */ +class MapHubDeviceAuthorization final : public QObject { + Q_OBJECT +public: + struct Result { + QString token; + QString organization_name; + }; + + explicit MapHubDeviceAuthorization(QString server_url, QString client_name, + QObject *parent = nullptr); + + void start(); + void cancel(); + bool isRunning() const { return running; } + +signals: + void verificationRequired(const QUrl &url, const QString &user_code); + void completed(const Result &result, const MapHubApiClient::Error &error); + +private: + void poll(); + void finish(const Result &result, const MapHubApiClient::Error &error); + + MapHubApiClient *client; + QTimer *poll_timer; + QString client_name; + QString request_id; + QString device_secret; + bool running = false; +}; + +} // namespace OpenOrienteering + +#endif diff --git a/src/gui/map_hub_dialog.cpp b/src/gui/map_hub_dialog.cpp index fc630a096..bf0fcd21f 100644 --- a/src/gui/map_hub_dialog.cpp +++ b/src/gui/map_hub_dialog.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,7 @@ #include "collaboration/managed_map_workspace.h" #include "collaboration/map_hub_api_client.h" #include "collaboration/map_hub_credentials.h" +#include "collaboration/map_hub_device_authorization.h" #include "collaboration/map_hub_imagery_catalog.h" #include "core/document_path.h" #include "gui/main_window.h" @@ -403,6 +405,8 @@ MapHubDialog::MapHubDialog(MainWindow *window) first_use_invite(new QLineEdit(first_use_page)), first_use_account_tabs(new QTabWidget(first_use_page)), first_use_browse(new QPushButton(tr("Choose…"), first_use_page)), + passkey_button(new QPushButton(tr("Connect with passkey…"), + first_use_page)), connect_button( new QPushButton(tr("Connect and open Map Hub"), first_use_page)), invitation_button(new QPushButton(tr("Set up account in browser…"), @@ -425,8 +429,9 @@ MapHubDialog::MapHubDialog(MainWindow *window) title_font.setBold(true); first_use_title->setFont(title_font); auto *first_use_intro = new QLabel( - tr("Use the invitation from your map librarian to create an account, or " - "connect an existing account token."), + tr("Connect Mapper to Map Hub with a passkey. The resulting secure " + "connection gives access to the library, workspaces, and authorized " + "imagery."), first_use_page); first_use_intro->setWordWrap(true); first_use_status->setWordWrap(true); @@ -442,12 +447,25 @@ MapHubDialog::MapHubDialog(MainWindow *window) connection_form->addRow(tr("Map Hub server:"), first_use_server); connection_form->addRow(tr("Local map workspaces:"), workspace_row); + auto *passkey_page = new QWidget(first_use_account_tabs); + auto *passkey_layout = new QVBoxLayout(passkey_page); + auto *passkey_help = new QLabel( + tr("Sign in in your browser with Touch ID, a security key, or another " + "passkey. Mapper returns here automatically after you approve this " + "device."), + passkey_page); + passkey_help->setWordWrap(true); + passkey_layout->addWidget(passkey_help); + passkey_layout->addWidget(passkey_button, 0, Qt::AlignLeft); + passkey_layout->addStretch(); + first_use_account_tabs->addTab(passkey_page, tr("Connect with passkey")); + auto *invitation_page = new QWidget(first_use_account_tabs); auto *invitation_form = new QFormLayout(invitation_page); first_use_invite->setEchoMode(QLineEdit::Password); auto *invitation_help = new QLabel( - tr("Account setup opens in your browser. A passkey is offered first; " - "you can choose a password there instead."), + tr("Use this if you need to create an account. When setup is complete, " + "return to the Connect with passkey tab."), invitation_page); invitation_help->setWordWrap(true); invitation_form->addRow(invitation_help); @@ -460,15 +478,13 @@ MapHubDialog::MapHubDialog(MainWindow *window) first_use_token->setEchoMode(QLineEdit::Password); first_use_token->setPlaceholderText(tr("Mapper API token")); auto *token_help = new QLabel( - tr("Use this if a Map Hub administrator gave you an account token " - "instead of an invitation."), + tr("Advanced fallback for a token provided by a Map Hub administrator."), token_page); token_help->setWordWrap(true); token_form->addRow(token_help); token_form->addRow(tr("Account token:"), first_use_token); token_form->addRow(connect_button); - first_use_account_tabs->addTab(token_page, - tr("Paste Mapper connection token")); + first_use_account_tabs->addTab(token_page, tr("Advanced token")); auto *first_use_close = new QPushButton(tr("Not now"), first_use_page); auto *first_use_buttons = new QHBoxLayout; @@ -519,6 +535,8 @@ MapHubDialog::MapHubDialog(MainWindow *window) layout->addWidget(pages); connect(first_use_browse, &QPushButton::clicked, this, &MapHubDialog::browseFirstUseWorkspace); + connect(passkey_button, &QPushButton::clicked, this, + &MapHubDialog::connectWithPasskey); connect(connect_button, &QPushButton::clicked, this, &MapHubDialog::connectExistingAccount); connect(invitation_button, &QPushButton::clicked, this, @@ -577,6 +595,7 @@ void MapHubDialog::setFirstUseBusy(bool value, const QString &message) { first_use_server->setEnabled(!value); first_use_workspace->setEnabled(!value); first_use_browse->setEnabled(!value); + passkey_button->setEnabled(!value); first_use_token->setEnabled(!value); first_use_invite->setEnabled(!value); first_use_account_tabs->setEnabled(!value); @@ -645,6 +664,55 @@ bool MapHubDialog::saveFirstUseConnection(const QString &server, return true; } +void MapHubDialog::connectWithPasskey() { + QString server; + QString workspace_root; + if (!firstUseConnection(server, workspace_root) || passkey_connection) + return; + + setFirstUseBusy(true, tr("Preparing secure browser sign-in…")); + const auto client_name = tr("Mapper on %1").arg(QSysInfo::machineHostName()); + passkey_connection = new MapHubDeviceAuthorization(server, client_name, this); + connect(passkey_connection, &MapHubDeviceAuthorization::verificationRequired, + this, [this](const QUrl &url, const QString &code) { + setFirstUseBusy( + true, + code.isEmpty() + ? tr("Finish the passkey sign-in in your browser…") + : tr("Finish the passkey sign-in in your browser. " + "Confirm code %1.") + .arg(code)); + if (!QDesktopServices::openUrl(url)) { + QMessageBox::warning(this, tr("Map Hub"), + tr("Mapper could not open Map Hub in your " + "browser.")); + passkey_connection->cancel(); + } + }); + connect(passkey_connection, &MapHubDeviceAuthorization::completed, this, + [this, server, workspace_root]( + const MapHubDeviceAuthorization::Result &result, + const MapHubApiClient::Error &error) { + auto *connection = passkey_connection.data(); + passkey_connection = nullptr; + if (connection) + connection->deleteLater(); + if (error) { + setFirstUseBusy(false, error.message); + return; + } + QString storage_error; + if (!saveFirstUseConnection(server, workspace_root, result.token, + storage_error)) { + setFirstUseBusy(false, storage_error); + return; + } + first_use_token->clear(); + refresh(); + }); + passkey_connection->start(); +} + void MapHubDialog::connectExistingAccount() { QString server; QString workspace_root; @@ -700,11 +768,9 @@ void MapHubDialog::openFirstUseInvitation() { "browser.")); return; } - first_use_account_tabs->setCurrentIndex(1); + first_use_account_tabs->setCurrentIndex(0); first_use_status->setText( - tr("Finish account setup in your browser, copy the Mapper connection " - "token, then paste it here.")); - first_use_token->setFocus(); + tr("Finish account setup in your browser, then connect with a passkey.")); } void MapHubDialog::setBusy(bool value, const QString &message) { diff --git a/src/gui/map_hub_dialog.h b/src/gui/map_hub_dialog.h index 72d527a2f..64279713e 100644 --- a/src/gui/map_hub_dialog.h +++ b/src/gui/map_hub_dialog.h @@ -25,6 +25,7 @@ class QWidget; namespace OpenOrienteering { class MainWindow; +class MapHubDeviceAuthorization; struct ManagedMapWorkspace; class MapHubDialog final : public QDialog { @@ -41,6 +42,7 @@ private slots: void createConnectedMap(); void updateActions(); void browseFirstUseWorkspace(); + void connectWithPasskey(); void connectExistingAccount(); void openFirstUseInvitation(); @@ -76,8 +78,10 @@ private slots: QLineEdit *first_use_invite; QTabWidget *first_use_account_tabs; QPushButton *first_use_browse; + QPushButton *passkey_button; QPushButton *connect_button; QPushButton *invitation_button; + QPointer passkey_connection; QLabel *connection_label; QLabel *activity_label; QTabWidget *tabs; diff --git a/src/gui/widgets/map_hub_settings_page.cpp b/src/gui/widgets/map_hub_settings_page.cpp index 5e162fea9..e549cbcd8 100644 --- a/src/gui/widgets/map_hub_settings_page.cpp +++ b/src/gui/widgets/map_hub_settings_page.cpp @@ -18,10 +18,12 @@ #include #include #include +#include #include #include "collaboration/map_hub_api_client.h" #include "collaboration/map_hub_credentials.h" +#include "collaboration/map_hub_device_authorization.h" #include "core/document_path.h" #include "gui/util_gui.h" #include "imagery/tile_network_manager.h" @@ -33,6 +35,7 @@ MapHubSettingsPage::MapHubSettingsPage(QWidget *parent) : SettingsPage(parent), server_edit(new QLineEdit(this)), workspace_root_edit(new QLineEdit(this)), token_edit(new QLineEdit(this)), credential_status(new QLabel(this)), + passkey_button(new QPushButton(tr("Connect with passkey…"), this)), test_button(new QPushButton(tr("Test connection"), this)), clear_button(new QPushButton(tr("Disconnect"), this)), invite_edit(new QLineEdit(this)), @@ -53,10 +56,19 @@ MapHubSettingsPage::MapHubSettingsPage(QWidget *parent) layout->addRow(tr("Local workspaces:"), workspace_widget); layout->addRow(Util::Headline::create(tr("Connected account"))); + auto *passkey_help = new QLabel( + tr("Connect Mapper in your browser with a passkey. The resulting " + "account credential is stored only in this device's secure " + "credential store and is used for all Map Hub features, including " + "protected imagery."), + this); + passkey_help->setWordWrap(true); + layout->addRow(passkey_help); + layout->addRow(passkey_button); token_edit->setEchoMode(QLineEdit::Password); token_edit->setPlaceholderText( - tr("Paste a Mapper API token to replace the stored token")); - layout->addRow(tr("Account token:"), token_edit); + tr("Advanced: paste a Mapper API token to replace the stored token")); + layout->addRow(tr("Advanced token:"), token_edit); credential_status->setWordWrap(true); layout->addRow(credential_status); auto *account_buttons = new QWidget(this); @@ -70,8 +82,8 @@ MapHubSettingsPage::MapHubSettingsPage(QWidget *parent) layout->addRow(Util::Headline::create(tr("Use an emailed invitation"))); invite_edit->setEchoMode(QLineEdit::Password); auto *invitation_help = new QLabel( - tr("Account setup opens in your browser. A passkey is offered first. " - "When setup finishes, paste the Mapper connection token above."), + tr("Use this only to create a new account. After setup, return here and " + "choose Connect with passkey."), this); invitation_help->setWordWrap(true); layout->addRow(invitation_help); @@ -89,6 +101,8 @@ MapHubSettingsPage::MapHubSettingsPage(QWidget *parent) &MapHubSettingsPage::testConnection); connect(clear_button, &QPushButton::clicked, this, &MapHubSettingsPage::clearCredential); + connect(passkey_button, &QPushButton::clicked, this, + &MapHubSettingsPage::connectWithPasskey); connect(invitation_button, &QPushButton::clicked, this, &MapHubSettingsPage::openInvitation); reset(); @@ -108,8 +122,7 @@ void MapHubSettingsPage::updateCredentialStatus() { credential_status->setText(tr("Credential error: %1").arg(result.error)); else if (result.token.isEmpty()) credential_status->setText( - tr("Not connected. Paste a Mapper connection token, or use an emailed " - "invitation to set up an account in your browser.")); + tr("Not connected. Connect with a passkey to use Map Hub.")); else if (result.used_fallback) credential_status->setText( tr("Connected. This system has no desktop secret service, so the token " @@ -187,11 +200,73 @@ void MapHubSettingsPage::reset() { } void MapHubSettingsPage::setBusy(bool busy) { + passkey_button->setEnabled(!busy); test_button->setEnabled(!busy); invitation_button->setEnabled(!busy); server_edit->setEnabled(!busy); } +void MapHubSettingsPage::connectWithPasskey() { + const auto server = server_edit->text().trimmed(); + if (!MapHubApiClient::isAcceptableServerUrl(QUrl::fromUserInput(server))) { + QMessageBox::warning(this, tr("Map Hub"), + tr("Enter an HTTPS Map Hub URL. HTTP is allowed only " + "for localhost development.")); + return; + } + if (passkey_connection) + return; + + setBusy(true); + credential_status->setText(tr("Preparing secure browser sign-in…")); + const auto client_name = tr("Mapper on %1").arg(QSysInfo::machineHostName()); + passkey_connection = new MapHubDeviceAuthorization(server, client_name, this); + connect(passkey_connection, &MapHubDeviceAuthorization::verificationRequired, + this, [this](const QUrl &url, const QString &code) { + credential_status->setText( + code.isEmpty() + ? tr("Finish the passkey sign-in in your browser…") + : tr("Finish the passkey sign-in in your browser. " + "Confirm code %1.") + .arg(code)); + if (!QDesktopServices::openUrl(url)) { + QMessageBox::warning(this, tr("Map Hub"), + tr("Mapper could not open Map Hub in your " + "browser.")); + passkey_connection->cancel(); + } + }); + connect(passkey_connection, &MapHubDeviceAuthorization::completed, this, + [this, server](const MapHubDeviceAuthorization::Result &result, + const MapHubApiClient::Error &error) { + auto *connection = passkey_connection.data(); + passkey_connection = nullptr; + if (connection) + connection->deleteLater(); + setBusy(false); + if (error) { + credential_status->setText(error.message); + return; + } + const auto stored = MapHubCredentials::writeToken(server, result.token); + if (!stored) { + credential_status->setText(stored.error); + return; + } + if (loaded_server != server) + imagery::TileNetworkManager::instance().clearBearerCredential( + QUrl(loaded_server)); + imagery::TileNetworkManager::instance().setBearerCredential( + QUrl(server), result.token.toUtf8(), + MapHubCredentials::accountName(server).toUtf8()); + setSetting(Settings::MapHub_ServerUrl, server); + loaded_server = server; + credential_status->setText( + tr("Connected to %1.").arg(result.organization_name)); + }); + passkey_connection->start(); +} + void MapHubSettingsPage::testConnection() { MapHubApiClient client(server_edit->text().trimmed(), effectiveToken(), this); if (!client.isConfigured()) { diff --git a/src/gui/widgets/map_hub_settings_page.h b/src/gui/widgets/map_hub_settings_page.h index 2732ee72e..520833d75 100644 --- a/src/gui/widgets/map_hub_settings_page.h +++ b/src/gui/widgets/map_hub_settings_page.h @@ -7,6 +7,8 @@ #ifndef OPENORIENTEERING_MAP_HUB_SETTINGS_PAGE_H #define OPENORIENTEERING_MAP_HUB_SETTINGS_PAGE_H +#include + #include "gui/widgets/settings_page.h" class QLabel; @@ -15,6 +17,8 @@ class QPushButton; namespace OpenOrienteering { +class MapHubDeviceAuthorization; + class MapHubSettingsPage final : public SettingsPage { Q_OBJECT public: @@ -28,6 +32,7 @@ public slots: private slots: void testConnection(); void clearCredential(); + void connectWithPasskey(); void openInvitation(); private: @@ -39,10 +44,12 @@ private slots: QLineEdit *workspace_root_edit; QLineEdit *token_edit; QLabel *credential_status; + QPushButton *passkey_button; QPushButton *test_button; QPushButton *clear_button; QLineEdit *invite_edit; QPushButton *invitation_button; + QPointer passkey_connection; QString loaded_server; }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 989a666f6..c55638efd 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -187,6 +187,7 @@ add_unit_test(locale_t ../src/util/translation_util) add_unit_test(map_hub_workspace_t ../src/collaboration/managed_map_workspace ../src/collaboration/map_hub_api_client + ../src/collaboration/map_hub_device_authorization ../src/collaboration/map_hub_imagery_catalog ../src/core/document_path ) diff --git a/test/map_hub_workspace_t.cpp b/test/map_hub_workspace_t.cpp index fc9e5939e..b0c5abf24 100644 --- a/test/map_hub_workspace_t.cpp +++ b/test/map_hub_workspace_t.cpp @@ -11,8 +11,11 @@ #include #include #include +#include +#include #include +#include "collaboration/map_hub_device_authorization.h" #include "collaboration/managed_map_workspace.h" #include "collaboration/map_hub_api_client.h" #include "collaboration/map_hub_imagery_catalog.h" @@ -20,6 +23,73 @@ using namespace OpenOrienteering; +namespace { + +class DeviceAuthorizationServer final : public QObject { +public: + DeviceAuthorizationServer() { + connect(&server, &QTcpServer::newConnection, this, [this] { + while (auto *socket = server.nextPendingConnection()) { + connect(socket, &QTcpSocket::readyRead, socket, + [this, socket] { handle(socket); }); + } + }); + } + + bool start() { return server.listen(QHostAddress::LocalHost); } + QString url() const { + return QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort()); + } + int requestCount() const { return request_count; } + bool valid() const { return error.isEmpty(); } + QString failure() const { return error; } + QString errorString() const { return server.errorString(); } + +private: + void handle(QTcpSocket *socket) { + auto request = socket->readAll(); + if (!request.contains("\r\n\r\n")) + return; + const auto path = request.left(request.indexOf("\r\n")); + QByteArray payload; + if (request_count == 0) { + if (!path.startsWith("POST /api/v1/auth/mapper/connect HTTP/1.1")) + error = QStringLiteral("unexpected start request"); + payload = QStringLiteral( + R"({"request_id":"11111111-1111-1111-1111-111111111111","device_secret":"a-connection-secret","user_code":"123456","verification_url":"%1/account/mapper/connect/11111111-1111-1111-1111-111111111111/","expires_in":10,"interval":1})") + .arg(url()) + .toUtf8(); + respond(socket, "201 Created", payload); + } else if (request_count == 1) { + if (!path.startsWith( + "POST /api/v1/auth/mapper/connect/11111111-1111-1111-1111-111111111111/exchange HTTP/1.1")) + error = QStringLiteral("unexpected exchange request"); + respond(socket, "202 Accepted", QByteArrayLiteral(R"({"status":"pending"})")); + } else if (request_count == 2) { + respond(socket, "201 Created", + QByteArrayLiteral(R"({"status":"connected","token":"cocm_connected","organization":{"name":"Cascade Orienteering Club"}})")); + } else { + error = QStringLiteral("unexpected extra connection request"); + respond(socket, "500 Internal Server Error", QByteArrayLiteral("{}")); + } + ++request_count; + } + + static void respond(QTcpSocket *socket, const QByteArray &status, + const QByteArray &payload) { + socket->write("HTTP/1.1 " + status + "\r\nContent-Type: application/json\r\n" + "Content-Length: " + QByteArray::number(payload.size()) + + "\r\nConnection: close\r\n\r\n" + payload); + socket->disconnectFromHost(); + } + + QTcpServer server; + int request_count = 0; + QString error; +}; + +} // namespace + void MapHubWorkspaceTest::initTestCase() { QCoreApplication::setOrganizationName(QStringLiteral("OpenOrienteeringTest")); QCoreApplication::setApplicationName(QStringLiteral("MapperMapHubTest")); @@ -150,6 +220,44 @@ void MapHubWorkspaceTest::hashesArtifactsExactly() { QVERIFY(error.isEmpty()); } +void MapHubWorkspaceTest::completesBrowserMediatedConnection() { + DeviceAuthorizationServer server; + QVERIFY2(server.start(), qPrintable(server.errorString())); + + MapHubDeviceAuthorization authorization( + server.url(), QStringLiteral("Mapper test"), this); + QUrl verification_url; + QString user_code; + MapHubDeviceAuthorization::Result result; + MapHubApiClient::Error error; + bool complete = false; + connect(&authorization, &MapHubDeviceAuthorization::verificationRequired, + this, [&verification_url, &user_code](const QUrl &url, + const QString &code) { + verification_url = url; + user_code = code; + }); + connect(&authorization, &MapHubDeviceAuthorization::completed, this, + [&result, &error, &complete]( + const MapHubDeviceAuthorization::Result &connected, + const MapHubApiClient::Error &connection_error) { + result = connected; + error = connection_error; + complete = true; + }); + + authorization.start(); + QTRY_VERIFY_WITH_TIMEOUT(verification_url.isValid(), 3000); + QCOMPARE(user_code, QStringLiteral("123456")); + QCOMPARE(verification_url.host(), QStringLiteral("127.0.0.1")); + QTRY_VERIFY_WITH_TIMEOUT(complete, 5000); + QVERIFY2(!error, qPrintable(error.message)); + QCOMPARE(result.token, QStringLiteral("cocm_connected")); + QCOMPARE(result.organization_name, QStringLiteral("Cascade Orienteering Club")); + QCOMPARE(server.requestCount(), 3); + QVERIFY2(server.valid(), qPrintable(server.failure())); +} + void MapHubWorkspaceTest::preservesPublishedTileMatrixLimits() { QJsonArray limits{ QJsonObject{{QStringLiteral("tileMatrix"), QStringLiteral("12")}, diff --git a/test/map_hub_workspace_t.h b/test/map_hub_workspace_t.h index 5f262701c..a219d6860 100644 --- a/test/map_hub_workspace_t.h +++ b/test/map_hub_workspace_t.h @@ -19,6 +19,7 @@ private slots: void identifiesMapperWorkspacePackageTypes(); void classifiesWorkspaceBaselines(); void hashesArtifactsExactly(); + void completesBrowserMediatedConnection(); void preservesPublishedTileMatrixLimits(); };