/* SPDX-FileCopyrightText: 2026 Project Tick
* SPDX-FileContributor: Project Tick
* SPDX-License-Identifier: GPL-3.0-or-later
*
* MeshMC - A Custom Launcher for Minecraft
* Copyright (C) 2026 Project Tick
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "katabasis/DeviceFlow.h"
#include "katabasis/PollServer.h"
#include "katabasis/Globals.h"
#include "JsonResponse.h"
namespace
{
// ref: https://tools.ietf.org/html/rfc8628#section-3.2
// Exception: Google sign-in uses "verification_url" instead of "*_uri" -
// we'll accept both.
bool hasMandatoryDeviceAuthParams(const QVariantMap& params)
{
if (!params.contains(Katabasis::OAUTH2_DEVICE_CODE))
return false;
if (!params.contains(Katabasis::OAUTH2_USER_CODE))
return false;
if (!(params.contains(Katabasis::OAUTH2_VERIFICATION_URI) ||
params.contains(Katabasis::OAUTH2_VERIFICATION_URL)))
return false;
if (!params.contains(Katabasis::OAUTH2_EXPIRES_IN))
return false;
return true;
}
QByteArray
createQueryParameters(const QList& parameters)
{
QByteArray ret;
bool first = true;
for (auto& h : parameters) {
if (first) {
first = false;
} else {
ret.append("&");
}
ret.append(QUrl::toPercentEncoding(h.name) + "=" +
QUrl::toPercentEncoding(h.value));
}
return ret;
}
} // namespace
namespace Katabasis
{
DeviceFlow::DeviceFlow(Options& opts, Token& token, QObject* parent,
QNetworkAccessManager* manager)
: QObject(parent), token_(token)
{
manager_ = manager ? manager : new QNetworkAccessManager(this);
qRegisterMetaType(
"QNetworkReply::NetworkError");
options_ = opts;
}
bool DeviceFlow::linked()
{
return token_.validity != Validity::None;
}
void DeviceFlow::setLinked(bool v)
{
qDebug() << "DeviceFlow::setLinked:" << (v ? "true" : "false");
token_.validity = v ? Validity::Certain : Validity::None;
}
void DeviceFlow::updateActivity(Activity activity)
{
if (activity_ == activity) {
return;
}
activity_ = activity;
switch (activity) {
case Katabasis::Activity::Idle:
case Katabasis::Activity::LoggingIn:
case Katabasis::Activity::LoggingOut:
case Katabasis::Activity::Refreshing:
// non-terminal states...
break;
case Katabasis::Activity::FailedSoft:
// terminal state, tokens did not change
break;
case Katabasis::Activity::FailedHard:
case Katabasis::Activity::FailedGone:
// terminal state, tokens are invalid
token_ = Token();
break;
case Katabasis::Activity::Succeeded:
setLinked(true);
break;
}
emit activityChanged(activity_);
}
QString DeviceFlow::token()
{
return token_.token;
}
void DeviceFlow::setToken(const QString& v)
{
token_.token = v;
}
QVariantMap DeviceFlow::extraTokens()
{
return token_.extra;
}
void DeviceFlow::setExtraTokens(QVariantMap extraTokens)
{
token_.extra = extraTokens;
}
void DeviceFlow::setPollServer(PollServer* server)
{
if (pollServer_)
pollServer_->deleteLater();
pollServer_ = server;
}
PollServer* DeviceFlow::pollServer() const
{
return pollServer_;
}
QVariantMap DeviceFlow::extraRequestParams()
{
return extraReqParams_;
}
void DeviceFlow::setExtraRequestParams(const QVariantMap& value)
{
extraReqParams_ = value;
}
QString DeviceFlow::grantType()
{
if (!grantType_.isEmpty())
return grantType_;
return OAUTH2_GRANT_TYPE_DEVICE;
}
void DeviceFlow::setGrantType(const QString& value)
{
grantType_ = value;
}
// First get the URL and token to display to the user
void DeviceFlow::login()
{
qDebug() << "DeviceFlow::link";
updateActivity(Activity::LoggingIn);
setLinked(false);
setToken("");
setExtraTokens(QVariantMap());
setRefreshToken(QString());
setExpires(QDateTime());
QList parameters;
parameters.append(RequestParameter(OAUTH2_CLIENT_ID,
options_.clientIdentifier.toUtf8()));
parameters.append(
RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8()));
QByteArray payload = createQueryParameters(parameters);
QUrl url(options_.authorizationUrl);
QNetworkRequest deviceRequest(url);
deviceRequest.setHeader(QNetworkRequest::ContentTypeHeader,
"application/x-www-form-urlencoded");
QNetworkReply* tokenReply = manager_->post(deviceRequest, payload);
connect(tokenReply, &QNetworkReply::finished, this,
&DeviceFlow::onDeviceAuthReplyFinished, Qt::QueuedConnection);
}
// Then, once we get them, present them to the user
void DeviceFlow::onDeviceAuthReplyFinished()
{
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished";
QNetworkReply* tokenReply = qobject_cast(sender());
if (!tokenReply) {
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: reply is null";
return;
}
if (tokenReply->error() == QNetworkReply::NoError) {
QByteArray replyData = tokenReply->readAll();
// Dump replyData
// SENSITIVE DATA in RelWithDebInfo or Debug builds
// qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: replyData\n";
// qDebug() << QString( replyData );
QVariantMap params = parseJsonResponse(replyData);
// Dump tokens
qDebug()
<< "DeviceFlow::onDeviceAuthReplyFinished: Tokens returned:\n";
foreach (QString key, params.keys()) {
// SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is
// truncated first
qDebug() << key << ": " << params.value(key).toString();
}
// Check for mandatory parameters
if (hasMandatoryDeviceAuthParams(params)) {
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: Device "
"auth request response";
const QString userCode =
params.take(OAUTH2_USER_CODE).toString();
QUrl uri = params.take(OAUTH2_VERIFICATION_URI).toUrl();
if (uri.isEmpty())
uri = params.take(OAUTH2_VERIFICATION_URL).toUrl();
if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE))
emit openBrowser(
params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl());
bool ok = false;
int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok);
if (!ok) {
qWarning() << "DeviceFlow::startPollServer: No expired_in "
"parameter";
updateActivity(Activity::FailedHard);
return;
}
emit showVerificationUriAndCode(uri, userCode, expiresIn);
startPollServer(params, expiresIn);
} else {
qWarning() << "DeviceFlow::onDeviceAuthReplyFinished: "
"Mandatory parameters missing from response";
updateActivity(Activity::FailedHard);
}
}
tokenReply->deleteLater();
}
// Spin up polling for the user completing the login flow out of band
void DeviceFlow::startPollServer(const QVariantMap& params, int expiresIn)
{
qDebug()
<< "DeviceFlow::startPollServer: device_ and user_code expires in"
<< expiresIn << "seconds";
QUrl url(options_.accessTokenUrl);
QNetworkRequest authRequest(url);
authRequest.setHeader(QNetworkRequest::ContentTypeHeader,
"application/x-www-form-urlencoded");
const QString deviceCode = params[OAUTH2_DEVICE_CODE].toString();
const QString grantType =
grantType_.isEmpty() ? OAUTH2_GRANT_TYPE_DEVICE : grantType_;
QList parameters;
parameters.append(RequestParameter(OAUTH2_CLIENT_ID,
options_.clientIdentifier.toUtf8()));
if (!options_.clientSecret.isEmpty()) {
parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET,
options_.clientSecret.toUtf8()));
}
parameters.append(RequestParameter(OAUTH2_CODE, deviceCode.toUtf8()));
parameters.append(
RequestParameter(OAUTH2_GRANT_TYPE, grantType.toUtf8()));
QByteArray payload = createQueryParameters(parameters);
PollServer* pollServer =
new PollServer(manager_, authRequest, payload, expiresIn, this);
if (params.contains(OAUTH2_INTERVAL)) {
bool ok = false;
int interval = params[OAUTH2_INTERVAL].toInt(&ok);
if (ok) {
pollServer->setInterval(interval);
}
}
connect(pollServer, &PollServer::verificationReceived, this,
&DeviceFlow::onVerificationReceived);
connect(pollServer, &PollServer::serverClosed, this,
&DeviceFlow::serverHasClosed);
setPollServer(pollServer);
pollServer->startPolling();
}
// Once the user completes the flow, update the internal state and report it
// to observers
void
DeviceFlow::onVerificationReceived(const QMap response)
{
qDebug()
<< "DeviceFlow::onVerificationReceived: Emitting closeBrowser()";
emit closeBrowser();
if (response.contains("error")) {
qWarning()
<< "DeviceFlow::onVerificationReceived: Verification failed:"
<< response;
updateActivity(Activity::FailedHard);
return;
}
// Check for mandatory tokens
if (response.contains(OAUTH2_ACCESS_TOKEN)) {
qDebug() << "DeviceFlow::onVerificationReceived: Access token "
"returned for implicit or device flow";
setToken(response.value(OAUTH2_ACCESS_TOKEN));
if (response.contains(OAUTH2_EXPIRES_IN)) {
bool ok = false;
int expiresIn = response.value(OAUTH2_EXPIRES_IN).toInt(&ok);
if (ok) {
qDebug() << "DeviceFlow::onVerificationReceived: Token "
"expires in"
<< expiresIn << "seconds";
setExpires(
QDateTime::currentDateTimeUtc().addSecs(expiresIn));
}
}
if (response.contains(OAUTH2_REFRESH_TOKEN)) {
setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN));
}
updateActivity(Activity::Succeeded);
} else {
qWarning() << "DeviceFlow::onVerificationReceived: Access token "
"missing from response for implicit or device flow";
updateActivity(Activity::FailedHard);
}
}
// Or if the flow fails or the polling times out, update the internal state
// with error and report it to observers
void DeviceFlow::serverHasClosed(bool paramsfound)
{
if (!paramsfound) {
// server has probably timed out after receiving first response
updateActivity(Activity::FailedHard);
}
// poll server is not re-used for later auth requests
setPollServer(NULL);
}
void DeviceFlow::logout()
{
qDebug() << "DeviceFlow::unlink";
updateActivity(Activity::LoggingOut);
// FIXME: implement logout flows... if they exist
token_ = Token();
updateActivity(Activity::FailedHard);
}
QDateTime DeviceFlow::expires()
{
return token_.notAfter;
}
void DeviceFlow::setExpires(QDateTime v)
{
token_.notAfter = v;
}
QString DeviceFlow::refreshToken()
{
return token_.refresh_token;
}
void DeviceFlow::setRefreshToken(const QString& v)
{
#ifndef NDEBUG
qDebug() << "DeviceFlow::setRefreshToken" << v << "...";
#endif
token_.refresh_token = v;
}
namespace
{
QByteArray buildRequestBody(const QMap& parameters)
{
QByteArray body;
bool first = true;
foreach (QString key, parameters.keys()) {
if (first) {
first = false;
} else {
body.append("&");
}
QString value = parameters.value(key);
body.append(QUrl::toPercentEncoding(key) +
QString("=").toUtf8() +
QUrl::toPercentEncoding(value));
}
return body;
}
} // namespace
bool DeviceFlow::refresh()
{
qDebug() << "DeviceFlow::refresh: Token: ..."
<< refreshToken().right(7);
updateActivity(Activity::Refreshing);
if (refreshToken().isEmpty()) {
qWarning() << "DeviceFlow::refresh: No refresh token";
onRefreshError(QNetworkReply::AuthenticationRequiredError, nullptr);
return false;
}
if (options_.accessTokenUrl.isEmpty()) {
qWarning() << "DeviceFlow::refresh: Refresh token URL not set";
onRefreshError(QNetworkReply::AuthenticationRequiredError, nullptr);
return false;
}
QNetworkRequest refreshRequest(options_.accessTokenUrl);
refreshRequest.setHeader(QNetworkRequest::ContentTypeHeader,
MIME_TYPE_XFORM);
QMap parameters;
parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier);
if (!options_.clientSecret.isEmpty()) {
parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret);
}
parameters.insert(OAUTH2_REFRESH_TOKEN, refreshToken());
parameters.insert(OAUTH2_GRANT_TYPE, OAUTH2_REFRESH_TOKEN);
QByteArray data = buildRequestBody(parameters);
QNetworkReply* refreshReply = manager_->post(refreshRequest, data);
timedReplies_.add(refreshReply);
connect(refreshReply, &QNetworkReply::finished, this,
&DeviceFlow::onRefreshFinished, Qt::QueuedConnection);
return true;
}
void DeviceFlow::onRefreshFinished()
{
QNetworkReply* refreshReply = qobject_cast(sender());
auto networkError = refreshReply->error();
if (networkError == QNetworkReply::NoError) {
QByteArray reply = refreshReply->readAll();
QVariantMap tokens = parseJsonResponse(reply);
setToken(tokens.value(OAUTH2_ACCESS_TOKEN).toString());
setExpires(QDateTime::currentDateTimeUtc().addSecs(
tokens.value(OAUTH2_EXPIRES_IN).toInt()));
QString refreshToken =
tokens.value(OAUTH2_REFRESH_TOKEN).toString();
if (!refreshToken.isEmpty()) {
setRefreshToken(refreshToken);
} else {
qDebug() << "No new refresh token. Keep the old one.";
}
timedReplies_.remove(refreshReply);
refreshReply->deleteLater();
updateActivity(Activity::Succeeded);
qDebug() << "New token expires in" << expires() << "seconds";
} else {
// FIXME: differentiate the error more here
onRefreshError(networkError, refreshReply);
}
}
void DeviceFlow::onRefreshError(QNetworkReply::NetworkError error,
QNetworkReply* refreshReply)
{
QString errorString = "No Reply";
if (refreshReply) {
timedReplies_.remove(refreshReply);
errorString = refreshReply->errorString();
}
switch (error) {
// used for invalid credentials and similar errors. Fall through.
case QNetworkReply::AuthenticationRequiredError:
case QNetworkReply::ContentAccessDenied:
case QNetworkReply::ContentOperationNotPermittedError:
case QNetworkReply::ProtocolInvalidOperationError:
updateActivity(Activity::FailedHard);
break;
case QNetworkReply::ContentGoneError: {
updateActivity(Activity::FailedGone);
break;
}
case QNetworkReply::TimeoutError:
case QNetworkReply::OperationCanceledError:
case QNetworkReply::SslHandshakeFailedError:
default:
updateActivity(Activity::FailedSoft);
return;
}
if (refreshReply) {
refreshReply->deleteLater();
}
qDebug() << "DeviceFlow::onRefreshFinished: Error" << (int)error
<< " - " << errorString;
}
} // namespace Katabasis