Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/collaboration/map_hub_api_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/collaboration/map_hub_api_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
131 changes: 131 additions & 0 deletions src/collaboration/map_hub_device_authorization.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright 2026 Ethan O'Connor
*
* This file is part of OpenOrienteering.
*/

#include "map_hub_device_authorization.h"

#include <QTimer>
#include <QUuid>

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
60 changes: 60 additions & 0 deletions src/collaboration/map_hub_device_authorization.h
Original file line number Diff line number Diff line change
@@ -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 <QPointer>
#include <QString>
#include <QUrl>

#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
Loading
Loading