From 191a343e61f2479f12eec7d4266dec34297049ec Mon Sep 17 00:00:00 2001 From: aglowinthefield <146008217+aglowinthefield@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:19:29 -0500 Subject: [PATCH 01/11] OAuth login first pass --- .gitignore | 5 +- src/CMakeLists.txt | 4 +- src/apiuseraccount.cpp | 10 +- src/apiuseraccount.h | 10 +- src/createinstancedialog.h | 2 +- src/createinstancedialog.ui | 18 +- src/createinstancedialogpages.cpp | 5 +- src/dlls.manifest.debug.qt6 | 1 + src/dlls.manifest.qt6 | 1 + src/moapplication.cpp | 6 +- src/modlistviewactions.cpp | 12 +- src/nexusinterface.cpp | 16 +- src/nexusmanualkey.ui | 231 --- src/nexusoauthconfig.cpp | 62 + src/nexusoauthconfig.h | 34 + src/nexusoauthlogin.cpp | 273 ++++ src/nexusoauthlogin.h | 72 + src/nexusoauthtokens.h | 104 ++ src/nxmaccessmanager.cpp | 354 ++--- src/nxmaccessmanager.h | 70 +- src/organizer_en.ts | 1284 +++++++++-------- src/organizercore.cpp | 12 +- src/pch.h | 1 - src/plugincontainer.cpp | 18 +- src/settings.cpp | 48 +- src/settings.h | 21 +- src/settingsdialog.ui | 20 +- src/settingsdialognexus.cpp | 161 +-- src/settingsdialognexus.h | 26 +- src/spawn.cpp | 2 + src/tutorials/tutorial_firststeps_settings.js | 9 +- 31 files changed, 1518 insertions(+), 1374 deletions(-) delete mode 100644 src/nexusmanualkey.ui create mode 100644 src/nexusoauthconfig.cpp create mode 100644 src/nexusoauthconfig.h create mode 100644 src/nexusoauthlogin.cpp create mode 100644 src/nexusoauthlogin.h create mode 100644 src/nexusoauthtokens.h diff --git a/.gitignore b/.gitignore index 935c42609..d1ac808a1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,12 @@ src/*.bak CMakeLists.txt.user edit /CMakeFiles -.idea +.idea/* +!.idea/filetypes/ +!.idea/filetypes/qt-translations.xml /msbuild.log /*std*.log /*build /src/version.aps +.idea/ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8b425272b..78d079d10 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,7 +12,7 @@ find_package(mo2-esptk CONFIG REQUIRED) find_package(mo2-dds-header CONFIG REQUIRED) find_package(mo2-libbsarch CONFIG REQUIRED) -find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebSockets) +find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebSockets NetworkAuth) find_package(Boost CONFIG REQUIRED COMPONENTS program_options thread interprocess signals2 uuid accumulators) find_package(7zip CONFIG REQUIRED) find_package(lz4 CONFIG REQUIRED) @@ -41,7 +41,7 @@ target_link_libraries(organizer PRIVATE usvfs::usvfs mo2::uibase mo2::archive mo2::libbsarch mo2::bsatk mo2::esptk mo2::lootcli-header Boost::program_options Boost::signals2 Boost::uuid Boost::accumulators - Qt6::WebEngineWidgets Qt6::WebSockets Version Dbghelp) + Qt6::WebEngineWidgets Qt6::WebSockets Qt6::NetworkAuth Version Dbghelp) install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/dlls.manifest.qt6" DESTINATION ${_bin}/dlls diff --git a/src/apiuseraccount.cpp b/src/apiuseraccount.cpp index 4b908df0c..d92c173fb 100644 --- a/src/apiuseraccount.cpp +++ b/src/apiuseraccount.cpp @@ -19,12 +19,12 @@ APIUserAccount::APIUserAccount() : m_type(APIUserAccountTypes::None) {} bool APIUserAccount::isValid() const { - return !m_key.isEmpty(); + return !m_accessToken.isEmpty(); } -const QString& APIUserAccount::apiKey() const +const QString& APIUserAccount::accessToken() const { - return m_key; + return m_accessToken; } const QString& APIUserAccount::id() const @@ -47,9 +47,9 @@ const APILimits& APIUserAccount::limits() const return m_limits; } -APIUserAccount& APIUserAccount::apiKey(const QString& key) +APIUserAccount& APIUserAccount::accessToken(const QString& token) { - m_key = key; + m_accessToken = token; return *this; } diff --git a/src/apiuseraccount.h b/src/apiuseraccount.h index 829654a54..1188a67cc 100644 --- a/src/apiuseraccount.h +++ b/src/apiuseraccount.h @@ -65,9 +65,9 @@ class APIUserAccount bool isValid() const; /** - * api key + * OAuth access token */ - const QString& apiKey() const; + const QString& accessToken() const; /** * user id @@ -90,9 +90,9 @@ class APIUserAccount const APILimits& limits() const; /** - * sets the api key + * sets the OAuth access token */ - APIUserAccount& apiKey(const QString& key); + APIUserAccount& accessToken(const QString& token); /** * sets the user id @@ -132,7 +132,7 @@ class APIUserAccount bool exhausted() const; private: - QString m_key, m_id, m_name; + QString m_accessToken, m_id, m_name; APIUserAccountTypes m_type; APILimits m_limits; }; diff --git a/src/createinstancedialog.h b/src/createinstancedialog.h index 4495cc78e..270f347f0 100644 --- a/src/createinstancedialog.h +++ b/src/createinstancedialog.h @@ -27,7 +27,7 @@ class Settings; // // pages can be disabled if they return true in skip(), which happens globally // for some (IntroPage has a setting in the registry), depending on context -// (NexusPage is skipped if the API key already exists) or explicitly (when +// (NexusPage is skipped if the Nexus authorization already exists) or explicitly (when // only some info about the instance is missing on startup, such as a game // variant) // diff --git a/src/createinstancedialog.ui b/src/createinstancedialog.ui index b47507559..b124b9af4 100644 --- a/src/createinstancedialog.ui +++ b/src/createinstancedialog.ui @@ -1095,18 +1095,11 @@ 20 - - - - - - Enter API Key Manually - - - - - - + + + + + @@ -1339,7 +1332,6 @@ overwrite browseOverwrite nexusConnect - nexusManual nexusLog review creationLog diff --git a/src/createinstancedialogpages.cpp b/src/createinstancedialogpages.cpp index 66f4c31cc..4853d8a6a 100644 --- a/src/createinstancedialogpages.cpp +++ b/src/createinstancedialogpages.cpp @@ -103,6 +103,7 @@ void Page::next() bool Page::action(CreateInstanceDialog::Actions a) { + Q_UNUSED(a); // no-op return false; } @@ -1199,11 +1200,11 @@ bool PathsPage::checkPath(QString path, PlaceholderLabel& existsLabel, NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg), m_skip(false) { m_connectionUI.reset(new NexusConnectionUI(&m_dlg, dlg.settings(), ui->nexusConnect, - nullptr, ui->nexusManual, ui->nexusLog)); + nullptr, ui->nexusLog)); // just check it once, or connecting and then going back and forth would skip // the page, which would be unexpected - m_skip = GlobalSettings::hasNexusApiKey(); + m_skip = GlobalSettings::hasNexusOAuthTokens(); } NexusPage::~NexusPage() = default; diff --git a/src/dlls.manifest.debug.qt6 b/src/dlls.manifest.debug.qt6 index e216a6fc0..eb0cf2845 100644 --- a/src/dlls.manifest.debug.qt6 +++ b/src/dlls.manifest.debug.qt6 @@ -12,6 +12,7 @@ + diff --git a/src/dlls.manifest.qt6 b/src/dlls.manifest.qt6 index 8f3ba1359..4702d1117 100644 --- a/src/dlls.manifest.qt6 +++ b/src/dlls.manifest.qt6 @@ -12,6 +12,7 @@ + diff --git a/src/moapplication.cpp b/src/moapplication.cpp index e676436af..7b0f7d425 100644 --- a/src/moapplication.cpp +++ b/src/moapplication.cpp @@ -319,9 +319,9 @@ int MOApplication::run(MOMultiProcess& multiProcess) tt.start("MOApplication::doOneRun() finishing"); // start an api check - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { - m_nexus->getAccessManager()->apiCheck(apiKey); + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens)) { + m_nexus->getAccessManager()->apiCheck(tokens); } // tutorials diff --git a/src/modlistviewactions.cpp b/src/modlistviewactions.cpp index 6b26a1e33..afd3b7fca 100644 --- a/src/modlistviewactions.cpp +++ b/src/modlistviewactions.cpp @@ -230,12 +230,12 @@ void ModListViewActions::checkModsForUpdates() const QString()); NexusInterface::instance().requestTrackingInfo(m_receiver, QVariant(), QString()); } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens)) { m_core.doAfterLogin([=]() { checkModsForUpdates(); }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + NexusInterface::instance().getAccessManager()->apiCheck(tokens); } else { log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " "under Settings -> Nexus.")); @@ -310,12 +310,12 @@ void ModListViewActions::checkModsForUpdates( if (NexusInterface::instance().getAccessManager()->validated()) { ModInfo::manualUpdateCheck(m_receiver, IDs); } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens)) { m_core.doAfterLogin([=]() { checkModsForUpdates(IDs); }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + NexusInterface::instance().getAccessManager()->apiCheck(tokens); } else log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " "under Settings -> Nexus.")); diff --git a/src/nexusinterface.cpp b/src/nexusinterface.cpp index 1486a59c1..9981ab0b0 100644 --- a/src/nexusinterface.cpp +++ b/src/nexusinterface.cpp @@ -989,7 +989,21 @@ void NexusInterface::nextRequest() request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); - request.setRawHeader("APIKEY", m_User.apiKey().toUtf8()); + if (!m_AccessManager->ensureFreshToken()) { + log::error("nexus: unable to refresh OAuth token, request aborted"); + info.m_Reply = nullptr; + return; + } + + const auto currentTokens = m_AccessManager->tokens(); + if (!currentTokens || currentTokens->accessToken.isEmpty()) { + log::error("nexus: no OAuth token available, request aborted"); + info.m_Reply = nullptr; + return; + } + + const auto bearer = QStringLiteral("Bearer %1").arg(currentTokens->accessToken); + request.setRawHeader("Authorization", bearer.toUtf8()); request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, m_AccessManager->userAgent(info.m_SubModule)); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, diff --git a/src/nexusmanualkey.ui b/src/nexusmanualkey.ui deleted file mode 100644 index 847bdd61d..000000000 --- a/src/nexusmanualkey.ui +++ /dev/null @@ -1,231 +0,0 @@ - - - NexusManualKeyDialog - - - - 0 - 0 - 452 - 236 - - - - Manual Nexus API Key - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 1. Get your personal API key - - - - - - - Open Browser - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 2. Enter your personal API key - - - - - - - Qt::Horizontal - - - - 115 - 20 - - - - - - - - Paste - - - - - - - Clear - - - - - - - - - - - 0 - 0 - - - - - 1 - 1 - - - - Enter API key here - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - <b>Sharing this key with anyone could get your Nexus account banned. You can always revoke this key from your account on the Nexus website.</b> - - - true - - - - - - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - NexusManualKeyDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - NexusManualKeyDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/src/nexusoauthconfig.cpp b/src/nexusoauthconfig.cpp new file mode 100644 index 000000000..370e0d6bf --- /dev/null +++ b/src/nexusoauthconfig.cpp @@ -0,0 +1,62 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer 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. + +Mod Organizer 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 Mod Organizer. If not, see . +*/ + +#include "nexusoauthconfig.h" +#include + +namespace +{ +QString envOrDefault(const char* name, const QString& fallback) +{ + const auto value = qEnvironmentVariable(name); + if (!value.isEmpty()) { + return value; + } + + return fallback; +} +} // namespace + +namespace NexusOAuth +{ +QString clientId() +{ + return envOrDefault("MO2_NEXUS_CLIENT_ID", QStringLiteral("modorganizer2")); +} + +quint16 redirectPort() +{ + return 28635; +} + +QString redirectUri() +{ + return QStringLiteral("http://127.0.0.1:%1/callback").arg(redirectPort()); +} + +QString authorizeUrl() +{ + return QStringLiteral("https://users.nexusmods.com/oauth/authorize"); +} + +QString tokenUrl() +{ + return QStringLiteral("https://users.nexusmods.com/oauth/token"); +} +} // namespace NexusOAuth diff --git a/src/nexusoauthconfig.h b/src/nexusoauthconfig.h new file mode 100644 index 000000000..e0ccd6312 --- /dev/null +++ b/src/nexusoauthconfig.h @@ -0,0 +1,34 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer 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. + +Mod Organizer 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 Mod Organizer. If not, see . +*/ + +#ifndef NEXUSOAUTHCONFIG_H +#define NEXUSOAUTHCONFIG_H + +#include + +namespace NexusOAuth +{ +QString clientId(); +QString redirectUri(); +quint16 redirectPort(); +QString authorizeUrl(); +QString tokenUrl(); +} // namespace NexusOAuth + +#endif // NEXUSOAUTHCONFIG_H diff --git a/src/nexusoauthlogin.cpp b/src/nexusoauthlogin.cpp new file mode 100644 index 000000000..b6a20d7e2 --- /dev/null +++ b/src/nexusoauthlogin.cpp @@ -0,0 +1,273 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer 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. + +Mod Organizer 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 Mod Organizer. If not, see . +*/ + +#include "nexusoauthlogin.h" +#include "nexusoauthconfig.h" +#include "utility.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +namespace +{ +QString callbackPath() +{ + return QUrl(NexusOAuth::redirectUri()).path(); +} +} // namespace + +NexusOAuthLogin::NexusOAuthLogin(QObject* parent) + : QObject(parent), m_active(false) +{} + +NexusOAuthLogin::~NexusOAuthLogin() = default; + +QString NexusOAuthLogin::stateToString(State state, const QString& details) +{ + switch (state) { + case State::Initializing: + return QObject::tr("Connecting to Nexus..."); + + case State::WaitingForBrowser: + return QObject::tr("Opened Nexus in browser.") + "\n" + + QObject::tr("Switch to your browser and accept the request."); + + case State::Authorizing: + return QObject::tr("Waiting for Nexus..."); + + case State::Finished: + return QObject::tr("Finished."); + + case State::Cancelled: + return QObject::tr("Cancelled."); + + case State::Error: + default: + return details.isEmpty() ? QObject::tr("An unknown error has occurred.") : details; + } +} + +void NexusOAuthLogin::start() +{ + if (m_active) { + cancel(); + } + + const auto clientId = NexusOAuth::clientId(); + if (clientId.isEmpty()) { + handleError(QObject::tr("No OAuth client id configured.")); + return; + } + + m_flow.reset(new QOAuth2AuthorizationCodeFlow); + m_flow->setAuthorizationUrl(QUrl(NexusOAuth::authorizeUrl())); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + m_flow->setAccessTokenUrl(QUrl(NexusOAuth::tokenUrl())); +#else + m_flow->setTokenUrl(QUrl(NexusOAuth::tokenUrl())); +#endif + m_flow->setClientIdentifier(clientId); + m_flow->setScope(QString()); +#if QT_VERSION < QT_VERSION_CHECK(6, 7, 0) + m_flow->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); +#endif + m_flow->setModifyParametersFunction( + [this](QAbstractOAuth::Stage stage, QMultiMap* parameters) { + injectPkceChallenge(stage, parameters); + }); + + m_replyHandler.reset( + new QOAuthHttpServerReplyHandler(QHostAddress::LocalHost, NexusOAuth::redirectPort(), + this)); + m_replyHandler->setCallbackPath(callbackPath()); + m_replyHandler->setCallbackText(QObject::tr( + "

Mod Organizer

Authorization complete. You may close this " + "window.

")); + if (!m_replyHandler->isListening() && + !m_replyHandler->listen(QHostAddress::LocalHost, NexusOAuth::redirectPort())) { + handleError(QObject::tr("Failed to bind to localhost on port %1.") + .arg(NexusOAuth::redirectPort())); + return; + } + + m_flow->setReplyHandler(m_replyHandler.get()); + + QObject::connect(m_flow.get(), &QAbstractOAuth::authorizeWithBrowser, this, + [&](const QUrl& url) { + shell::Open(url); + setState(State::WaitingForBrowser); + }); + + QObject::connect(m_flow.get(), &QAbstractOAuth::statusChanged, this, + [&](QAbstractOAuth::Status status) { + switch (status) { + case QAbstractOAuth::Status::RefreshingToken: + case QAbstractOAuth::Status::TemporaryCredentialsReceived: + setState(State::Authorizing); + break; + + case QAbstractOAuth::Status::Granted: + setState(State::Finished); + break; + + default: + break; + } + }); + + QObject::connect(m_flow.get(), &QAbstractOAuth::requestFailed, this, + [&](QAbstractOAuth::Error error) { + handleError(QObject::tr("Authorization failed (%1)").arg(int(error))); + }); + + QObject::connect(m_flow.get(), &QAbstractOAuth::granted, this, [&] { + notifyTokens(); + }); + + m_active = true; + setState(State::Initializing); + m_flow->grant(); +} + +void NexusOAuthLogin::cancel() +{ + if (m_replyHandler) { + m_replyHandler->close(); + } + + m_flow.reset(); + m_replyHandler.reset(); + if (m_active) { + m_active = false; + setState(State::Cancelled); + } +} + +bool NexusOAuthLogin::isActive() const +{ + return m_active; +} + +void NexusOAuthLogin::setState(State state, const QString& message) +{ + if (stateChanged) { + stateChanged(state, message); + } +} + +void NexusOAuthLogin::notifyTokens() +{ + if (!m_flow) { + handleError(QObject::tr("Internal error: OAuth flow is missing.")); + return; + } + + QJsonObject payload; + payload.insert(QStringLiteral("access_token"), m_flow->token()); + + const auto extras = m_flow->extraTokens(); + for (auto it = extras.constBegin(); it != extras.constEnd(); ++it) { + payload.insert(it.key(), QJsonValue::fromVariant(it.value())); + } + + auto tokens = makeTokensFromResponse(payload); + if (!tokens.isValid()) { + handleError(QObject::tr("Invalid OAuth token payload.")); + return; + } + + tokens.scope = m_flow->scope(); + + m_flow.reset(); + m_replyHandler.reset(); + m_codeVerifier.clear(); + m_active = false; + if (tokensReceived) { + tokensReceived(tokens); + } +} + +void NexusOAuthLogin::handleError(const QString& message) +{ + if (m_replyHandler) { + m_replyHandler->close(); + } + m_flow.reset(); + m_replyHandler.reset(); + m_codeVerifier.clear(); + m_active = false; + if (stateChanged) { + stateChanged(State::Error, message); + } +} + +namespace +{ +QByteArray randomBytes(int length) +{ + QByteArray bytes; + bytes.resize(length); + QRandomGenerator::system()->generate(bytes.begin(), bytes.end()); + return bytes; +} +} + +void NexusOAuthLogin::injectPkceChallenge(QAbstractOAuth::Stage stage, + QMultiMap* parameters) +{ + if (!parameters) { + return; + } + + switch (stage) { + case QAbstractOAuth::Stage::RequestingAuthorization: { + m_codeVerifier = randomBytes(32).toBase64(QByteArray::Base64UrlEncoding | + QByteArray::OmitTrailingEquals); + const auto challenge = + QCryptographicHash::hash(m_codeVerifier, QCryptographicHash::Sha256) + .toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + + parameters->insert(QStringLiteral("code_challenge"), QString::fromUtf8(challenge)); + parameters->insert(QStringLiteral("code_challenge_method"), QStringLiteral("S256")); + parameters->insert(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); + break; + } + + case QAbstractOAuth::Stage::RequestingAccessToken: { + if (!m_codeVerifier.isEmpty()) { + parameters->insert(QStringLiteral("code_verifier"), QString::fromUtf8(m_codeVerifier)); + } + parameters->insert(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); + break; + } + + default: + break; + } +} diff --git a/src/nexusoauthlogin.h b/src/nexusoauthlogin.h new file mode 100644 index 000000000..6a21b7d07 --- /dev/null +++ b/src/nexusoauthlogin.h @@ -0,0 +1,72 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer 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. + +Mod Organizer 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 Mod Organizer. If not, see . +*/ + +#ifndef NEXUSOAUTHLOGIN_H +#define NEXUSOAUTHLOGIN_H + +#include "nexusoauthtokens.h" +#include +#include +#include + +class QOAuth2AuthorizationCodeFlow; +class QOAuthHttpServerReplyHandler; + +class NexusOAuthLogin : public QObject +{ + Q_OBJECT + +public: + enum class State + { + Initializing, + WaitingForBrowser, + Authorizing, + Finished, + Cancelled, + Error + }; + + explicit NexusOAuthLogin(QObject* parent = nullptr); + ~NexusOAuthLogin(); + + void start(); + void cancel(); + bool isActive() const; + + std::function tokensReceived; + std::function stateChanged; + + static QString stateToString(State state, const QString& details = {}); + +private: + std::unique_ptr m_flow; + std::unique_ptr m_replyHandler; + bool m_active; + + void setState(State state, const QString& message = {}); + void notifyTokens(); + void handleError(const QString& message); + void injectPkceChallenge(QAbstractOAuth::Stage stage, + QMultiMap* parameters); + + QByteArray m_codeVerifier; +}; + +#endif // NEXUSOAUTHLOGIN_H diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h new file mode 100644 index 000000000..4ce719f2f --- /dev/null +++ b/src/nexusoauthtokens.h @@ -0,0 +1,104 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer 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. + +Mod Organizer 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 Mod Organizer. If not, see . +*/ + +#ifndef NEXUSOAUTHTOKENS_H +#define NEXUSOAUTHTOKENS_H + +#include +#include +#include +#include + +struct NexusOAuthTokens +{ + QString accessToken; + QString refreshToken; + QString scope; + QString tokenType; + QDateTime expiresAt; + + bool isValid() const { return !accessToken.isEmpty() && expiresAt.isValid(); } + + bool isExpired(std::chrono::seconds skew = std::chrono::seconds(60)) const + { + if (!expiresAt.isValid()) { + return true; + } + + const auto now = QDateTime::currentDateTimeUtc(); + return now.addSecs(skew.count()) >= expiresAt; + } + + QJsonObject toJson() const + { + QJsonObject json; + json.insert(QStringLiteral("access_token"), accessToken); + json.insert(QStringLiteral("refresh_token"), refreshToken); + json.insert(QStringLiteral("scope"), scope); + json.insert(QStringLiteral("token_type"), tokenType); + json.insert(QStringLiteral("expires_at"), expiresAt.toString(Qt::ISODateWithMs)); + return json; + } + + static std::optional fromJson(const QJsonObject& json) + { + NexusOAuthTokens tokens; + tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); + tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); + tokens.scope = json.value(QStringLiteral("scope")).toString(); + tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); + tokens.expiresAt = + QDateTime::fromString(json.value(QStringLiteral("expires_at")).toString(), + Qt::ISODateWithMs); + if (!tokens.expiresAt.isValid()) { + tokens.expiresAt = + QDateTime::fromString(json.value(QStringLiteral("expires_at")).toString(), + Qt::ISODate); + } + + if (!tokens.isValid()) { + return std::nullopt; + } + + if (tokens.expiresAt.isValid() && tokens.expiresAt.timeSpec() != Qt::UTC) { + tokens.expiresAt = tokens.expiresAt.toUTC(); + } + + return tokens; + } +}; + +inline NexusOAuthTokens makeTokensFromResponse(const QJsonObject& json) +{ + NexusOAuthTokens tokens; + tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); + tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); + tokens.scope = json.value(QStringLiteral("scope")).toString(); + tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); + + const auto expiresIn = json.value(QStringLiteral("expires_in")).toInt(); + if (expiresIn > 0) { + tokens.expiresAt = + QDateTime::currentDateTimeUtc().addSecs(static_cast(expiresIn)); + } + + return tokens; +} + +#endif // NEXUSOAUTHTOKENS_H diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 3efdcda01..4b65139d9 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -20,6 +20,7 @@ along with Mod Organizer. If not, see . #include "nxmaccessmanager.h" #include "iplugingame.h" #include "nexusinterface.h" +#include "nexusoauthconfig.h" #include "nxmurl.h" #include "persistentcookiejar.h" #include "report.h" @@ -36,6 +37,7 @@ along with Mod Organizer. If not, see . #include #include #include +#include #include #include @@ -43,9 +45,6 @@ using namespace MOBase; using namespace std::chrono_literals; const QString NexusBaseUrl("https://api.nexusmods.com/v1"); -const QString NexusSSO("wss://sso.nexusmods.com"); -const QString - NexusSSOPage("https://www.nexusmods.com/sso?id=%1&application=modorganizer2"); ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) @@ -109,6 +108,8 @@ void ValidationProgressDialog::showEvent(QShowEvent* e) m_first = false; } + + QDialog::showEvent(e); } void ValidationProgressDialog::closeEvent(QCloseEvent* e) @@ -153,211 +154,6 @@ void ValidationProgressDialog::updateProgress() } } -NexusSSOLogin::NexusSSOLogin() : m_keyReceived(false), m_active(false) -{ - m_timeout.setInterval(10s); - m_timeout.setSingleShot(true); - - QObject::connect(&m_socket, &QWebSocket::connected, [&] { - onConnected(); - }); - - QObject::connect(&m_socket, - qOverload(&QWebSocket::error), - [&](auto&& e) { - onError(e); - }); - - QObject::connect(&m_socket, &QWebSocket::sslErrors, [&](auto&& errors) { - onSslErrors(errors); - }); - - QObject::connect(&m_socket, &QWebSocket::textMessageReceived, [&](auto&& s) { - onMessage(s); - }); - - QObject::connect(&m_socket, &QWebSocket::disconnected, [&] { - onDisconnected(); - }); - - QObject::connect(&m_timeout, &QTimer::timeout, [&] { - onTimeout(); - }); -} - -QString NexusSSOLogin::stateToString(States s, const QString& e) -{ - switch (s) { - case ConnectingToSSO: - return QObject::tr("Connecting to Nexus..."); - - case WaitingForToken: - return QObject::tr("Waiting for Nexus..."); - - case WaitingForBrowser: - return QObject::tr("Opened Nexus in browser.") + "\n" + - QObject::tr("Switch to your browser and accept the request."); - - case Finished: - return QObject::tr("Finished."); - - case Timeout: - return QObject::tr("No answer from Nexus.") + "\n" + - QObject::tr("A firewall might be blocking Mod Organizer."); - - case ClosedByRemote: - return QObject::tr("Nexus closed the connection.") + "\n" + - QObject::tr("A firewall might be blocking Mod Organizer."); - - case Cancelled: - return QObject::tr("Cancelled."); - - case Error: // fall-through - default: { - if (e.isEmpty()) { - return QString("%1").arg(s); - } else { - return e; - } - } - } -} - -void NexusSSOLogin::start() -{ - m_active = true; - setState(ConnectingToSSO); - m_timeout.start(); - m_socket.open(NexusSSO); -} - -void NexusSSOLogin::cancel() -{ - if (m_active) { - abort(); - setState(Cancelled); - } -} - -void NexusSSOLogin::close() -{ - if (m_active) { - m_active = false; - m_timeout.stop(); - m_socket.close(); - } -} - -void NexusSSOLogin::abort() -{ - m_active = false; - m_timeout.stop(); - m_socket.abort(); -} - -bool NexusSSOLogin::isActive() const -{ - return m_active; -} - -void NexusSSOLogin::setState(States s, const QString& error) -{ - if (stateChanged) { - stateChanged(s, error); - } -} - -void NexusSSOLogin::onConnected() -{ - setState(WaitingForToken); - - m_keyReceived = false; - - boost::uuids::random_generator generator; - boost::uuids::uuid sessionId = generator(); - m_guid = boost::uuids::to_string(sessionId).c_str(); - - QJsonObject data; - data.insert(QString("id"), QJsonValue(m_guid)); - data.insert(QString("protocol"), 2); - - const QString message = QJsonDocument(data).toJson(); - m_socket.sendTextMessage(message); -} - -void NexusSSOLogin::onMessage(const QString& s) -{ - const QJsonDocument doc = QJsonDocument::fromJson(s.toUtf8()); - const QVariantMap root = doc.object().toVariantMap(); - - if (!root["success"].toBool()) { - close(); - - setState(Error, QString("There was a problem with SSO initialization: %1") - .arg(root["error"].toString())); - - return; - } - - const QVariantMap data = root["data"].toMap(); - - if (data.contains("connection_token")) { - // first answer - - // open browser - const QUrl url = NexusSSOPage.arg(m_guid); - shell::Open(url); - - m_timeout.stop(); - setState(WaitingForBrowser); - } else { - // second answer - const auto key = data["api_key"].toString(); - close(); - - if (keyChanged) { - keyChanged(key); - } - - setState(Finished); - } -} - -void NexusSSOLogin::onDisconnected() -{ - if (m_active) { - if (!m_keyReceived) { - close(); - setState(ClosedByRemote); - } else { - m_active = false; - } - } -} - -void NexusSSOLogin::onError(QAbstractSocket::SocketError e) -{ - if (m_active) { - close(); - setState(Error, m_socket.errorString()); - } -} - -void NexusSSOLogin::onSslErrors(const QList& errors) -{ - if (m_active) { - for (const auto& e : errors) { - setState(Error, e.errorString()); - } - } -} - -void NexusSSOLogin::onTimeout() -{ - abort(); - setState(Timeout); -} - ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) : m_reply(nullptr), m_result(None) { @@ -369,9 +165,11 @@ ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) }); } -void ValidationAttempt::start(NXMAccessManager& m, const QString& key) +void ValidationAttempt::start(NXMAccessManager& m, const NexusOAuthTokens& tokens) { - if (!sendRequest(m, key)) { + m_tokens = tokens; + + if (!sendRequest(m, tokens)) { return; } @@ -381,12 +179,18 @@ void ValidationAttempt::start(NXMAccessManager& m, const QString& key) log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); } -bool ValidationAttempt::sendRequest(NXMAccessManager& m, const QString& key) +bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens) { const QString requestUrl(NexusBaseUrl + "/users/validate"); QNetworkRequest request(requestUrl); - request.setRawHeader("APIKEY", key.toUtf8()); + if (tokens.accessToken.isEmpty()) { + setFailure(HardError, QObject::tr("Access token is empty")); + return false; + } + + const auto bearer = QStringLiteral("Bearer %1").arg(tokens.accessToken); + request.setRawHeader("Authorization", bearer.toUtf8()); request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, m.userAgent().toUtf8()); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, @@ -521,18 +325,17 @@ void ValidationAttempt::onFinished() } const int id = data.value("user_id").toInt(); - const QString key = data.value("key").toString(); const QString name = data.value("name").toString(); const bool premium = data.value("is_premium").toBool(); - if (key.isEmpty()) { - setFailure(HardError, QObject::tr("API key is empty")); + if (m_tokens.accessToken.isEmpty()) { + setFailure(HardError, QObject::tr("Access token is empty")); return; } const auto user = APIUserAccount() - .apiKey(key) + .accessToken(m_tokens.accessToken) .id(QString("%1").arg(id)) .name(name) .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) @@ -616,14 +419,14 @@ std::vector NexusKeyValidator::getTimeouts() const } } -void NexusKeyValidator::start(const QString& key, Behaviour b) +void NexusKeyValidator::start(const NexusOAuthTokens& tokens, Behaviour b) { if (isActive()) { log::debug("nexus: trying to start while ongoing; ignoring"); return; } - m_key = key; + m_tokens = tokens; const auto timeouts = getTimeouts(); @@ -700,6 +503,11 @@ const ValidationAttempt* NexusKeyValidator::currentAttempt() const bool NexusKeyValidator::nextTry() { + if (!m_tokens) { + log::error("nexus: validator invoked without tokens"); + return false; + } + for (auto&& a : m_attempts) { if (!a->done()) { a->success = [&](auto&& user) { @@ -709,7 +517,7 @@ bool NexusKeyValidator::nextTry() onAttemptFailure(*a); }; - a->start(m_manager, m_key); + a->start(m_manager, *m_tokens); return true; } } @@ -835,10 +643,41 @@ void NXMAccessManager::clearCookies() } } -void NXMAccessManager::startValidationCheck(const QString& key) +void NXMAccessManager::setTokens(const NexusOAuthTokens& tokens) +{ + m_tokens = tokens; +} + +std::optional NXMAccessManager::tokens() const +{ + return m_tokens; +} + +bool NXMAccessManager::ensureFreshToken() +{ + if (!m_tokens) { + log::warn("nexus: no OAuth tokens available"); + return false; + } + + if (!m_tokens->isExpired()) { + return true; + } + + const auto refreshed = refreshTokensBlocking(*m_tokens); + if (!refreshed) { + return false; + } + + setTokens(*refreshed); + GlobalSettings::setNexusOAuthTokens(*refreshed); + return true; +} + +void NXMAccessManager::startValidationCheck(const NexusOAuthTokens& tokens) { m_validationState = NotChecked; - m_validator.start(key, NexusKeyValidator::Retry); + m_validator.start(tokens, NexusKeyValidator::Retry); if (m_ProgressDialog) { // don't show the progress dialog on startup for the first attempt; the @@ -847,6 +686,70 @@ void NXMAccessManager::startValidationCheck(const QString& key) } } +std::optional +NXMAccessManager::refreshTokensBlocking(const NexusOAuthTokens& current) +{ + if (current.refreshToken.isEmpty()) { + log::error("nexus: refresh token missing, user interaction required"); + return std::nullopt; + } + + QNetworkRequest request{QUrl(NexusOAuth::tokenUrl())}; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/x-www-form-urlencoded"); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + userAgent().toUtf8()); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", MOVersion().toUtf8()); + + QUrlQuery formData; + formData.addQueryItem(QStringLiteral("grant_type"), + QStringLiteral("refresh_token")); + formData.addQueryItem(QStringLiteral("refresh_token"), current.refreshToken); + formData.addQueryItem(QStringLiteral("client_id"), NexusOAuth::clientId()); + + auto reply = post(request, formData.toString(QUrl::FullyEncoded).toUtf8()); + if (!reply) { + log::error("nexus: failed to issue refresh token request"); + return std::nullopt; + } + + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + if (reply->error() != QNetworkReply::NoError) { + log::error("nexus: refresh token request failed - {}", reply->errorString()); + reply->deleteLater(); + return std::nullopt; + } + + const auto payload = QJsonDocument::fromJson(reply->readAll()); + reply->deleteLater(); + if (!payload.isObject()) { + log::error("nexus: invalid refresh token payload"); + return std::nullopt; + } + + auto tokens = makeTokensFromResponse(payload.object()); + if (tokens.refreshToken.isEmpty()) { + tokens.refreshToken = current.refreshToken; + } + if (tokens.scope.isEmpty()) { + tokens.scope = current.scope; + } + if (tokens.tokenType.isEmpty()) { + tokens.tokenType = current.tokenType; + } + + if (!tokens.isValid()) { + return std::nullopt; + } + + return tokens; +} + void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional user) @@ -916,12 +819,14 @@ bool NXMAccessManager::validateWaiting() const return m_validator.isActive(); } -void NXMAccessManager::apiCheck(const QString& apiKey, bool force) +void NXMAccessManager::apiCheck(const NexusOAuthTokens& tokens, bool force) { if (m_validator.isActive()) { return; } + setTokens(tokens); + if (m_Settings && m_Settings->network().offlineMode()) { m_validationState = NotChecked; return; @@ -936,7 +841,7 @@ void NXMAccessManager::apiCheck(const QString& apiKey, bool force) return; } - startValidationCheck(apiKey); + startValidationCheck(tokens); } const QString& NXMAccessManager::MOVersion() const @@ -964,9 +869,10 @@ QString NXMAccessManager::userAgent(const QString& subModule) const .arg(m_MOVersion, comments.join("; "), qVersion()); } -void NXMAccessManager::clearApiKey() +void NXMAccessManager::clearTokens() { m_validator.cancel(); + m_tokens.reset(); emit credentialsReceived(APIUserAccount()); } diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index 79371a457..1b051572d 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -21,6 +21,7 @@ along with Mod Organizer. If not, see . #define NXMACCESSMANAGER_H #include "apiuseraccount.h" +#include "nexusoauthtokens.h" #include "ui_validationprogressdialog.h" #include #include @@ -28,7 +29,7 @@ along with Mod Organizer. If not, see . #include #include #include -#include +#include #include namespace MOBase @@ -38,53 +39,6 @@ class IPluginGame; class NXMAccessManager; class Settings; -class NexusSSOLogin -{ -public: - enum States - { - ConnectingToSSO, - WaitingForToken, - WaitingForBrowser, - Finished, - Timeout, - ClosedByRemote, - Cancelled, - Error - }; - - std::function keyChanged; - std::function stateChanged; - - static QString stateToString(States s, const QString& e); - - NexusSSOLogin(); - - void start(); - void cancel(); - - bool isActive() const; - -private: - QWebSocket m_socket; - QString m_guid; - bool m_keyReceived; - bool m_active; - QTimer m_timeout; - - void setState(States s, const QString& error = {}); - - void close(); - void abort(); - - void onConnected(); - void onMessage(const QString& s); - void onDisconnected(); - void onError(QAbstractSocket::SocketError e); - void onSslErrors(const QList& errors); - void onTimeout(); -}; - class ValidationAttempt { public: @@ -104,7 +58,7 @@ class ValidationAttempt ValidationAttempt(const ValidationAttempt&) = delete; ValidationAttempt& operator=(const ValidationAttempt&) = delete; - void start(NXMAccessManager& m, const QString& key); + void start(NXMAccessManager& m, const NexusOAuthTokens& tokens); void cancel(); bool done() const; @@ -119,8 +73,9 @@ class ValidationAttempt QString m_message; QTimer m_timeout; QElapsedTimer m_elapsed; + NexusOAuthTokens m_tokens; - bool sendRequest(NXMAccessManager& m, const QString& key); + bool sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens); void onFinished(); void onSslErrors(const QList& errors); @@ -150,7 +105,7 @@ class NexusKeyValidator NexusKeyValidator(Settings* s, NXMAccessManager& am); ~NexusKeyValidator(); - void start(const QString& key, Behaviour b); + void start(const NexusOAuthTokens& tokens, Behaviour b); void cancel(); bool isActive() const; @@ -160,7 +115,7 @@ class NexusKeyValidator private: Settings* m_settings; NXMAccessManager& m_manager; - QString m_key; + std::optional m_tokens; std::vector> m_attempts; void createAttempts(const std::vector& timeouts); @@ -219,7 +174,10 @@ class NXMAccessManager : public QNetworkAccessManager bool validateAttempted() const; bool validateWaiting() const; - void apiCheck(const QString& apiKey, bool force = false); + void apiCheck(const NexusOAuthTokens& tokens, bool force = false); + void setTokens(const NexusOAuthTokens& tokens); + std::optional tokens() const; + bool ensureFreshToken(); void showCookies() const; @@ -228,7 +186,7 @@ class NXMAccessManager : public QNetworkAccessManager QString userAgent(const QString& subModule = QString()) const; const QString& MOVersion() const; - void clearApiKey(); + void clearTokens(); void refuseValidation(); @@ -270,8 +228,10 @@ class NXMAccessManager : public QNetworkAccessManager QString m_MOVersion; NexusKeyValidator m_validator; States m_validationState; + std::optional m_tokens; - void startValidationCheck(const QString& key); + void startValidationCheck(const NexusOAuthTokens& tokens); + std::optional refreshTokensBlocking(const NexusOAuthTokens& current); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional); diff --git a/src/organizer_en.ts b/src/organizer_en.ts index b4d852907..abcf2fd2f 100644 --- a/src/organizer_en.ts +++ b/src/organizer_en.ts @@ -710,42 +710,37 @@ p, li { white-space: pre-wrap; } - - Enter API Key Manually - - - - + <h3>Confirmation</h3> - + The instance is about to be created. Review the information below and press 'Finish'. - + Instance creation log - + Launch the new instance - + < Back - + Next > - + Cancel @@ -1008,145 +1003,150 @@ p, li { white-space: pre-wrap; } + Visit the uploader's profile + + + + Open File - + Open Meta File - - - + + + Reveal in Explorer - - + + Delete... - + Un-Hide - + Hide - - + + Cancel - + Pause - + Resume - + Delete Installed Downloads... - + Delete Uninstalled Downloads... - + Delete All Downloads... - + Hide Installed... - + Hide Uninstalled... - + Hide All... - + Un-Hide All... - + Delete download - + Move to the Recycle Bin - - - + + + Delete Files? - + This will remove all finished downloads from this list and from disk. Are you absolutely sure you want to proceed? - + This will remove all installed downloads from this list and from disk. Are you absolutely sure you want to proceed? - + This will remove all uninstalled downloads from this list and from disk. Are you absolutely sure you want to proceed? - - - + + + Hide Files? - + This will remove all finished downloads from this list (but NOT from disk). - + This will remove all installed downloads from this list (but NOT from disk). - + This will remove all uninstalled downloads from this list (but NOT from disk). @@ -1154,22 +1154,22 @@ Are you absolutely sure you want to proceed? DownloadManager - + failed to rename "%1" to "%2" - + Memory allocation error (in refreshing directory). - + Query Metadata - + There are %1 downloads with incomplete metadata. Do you want to fetch all incomplete metadata? @@ -1177,32 +1177,32 @@ API requests will be consumed, and Mod Organizer may stutter. - + failed to download %1: could not open output file: %2 - + Download again? - + A file with the same name "%1" has already been downloaded. Do you want to download it again? The new file will receive a different name. - + Wrong Game - + The download link is for a mod for "%1" but this instance of MO has been set up for "%2". - + There is already a download queued for this file. Mod %1 @@ -1210,12 +1210,12 @@ File %2 - + Already Queued - + There is already a download started for this file. Mod %1: %2 @@ -1223,287 +1223,297 @@ File %3: %4 - + Already Started - - + + remove: invalid download index %1 - + failed to delete %1 - + failed to delete meta file for %1 - + restore: invalid download index: %1 - + cancel: invalid download index %1 - + pause: invalid download index %1 - + resume: invalid download index %1 - + resume (int): invalid download index %1 - + No known download urls. Sorry, this download can't be resumed. - - + + query: invalid download index %1 - + Please enter the Nexus mod ID - + Mod ID: - + Please select the source game code for %1 - + Hashing download file '%1' - + Cancel - + VisitNexus: invalid download index %1 - + Nexus ID for this Mod is unknown - + + VisitUploaderProfile: invalid download index %1 + + + + + Uploader for this Mod is unknown + + + + OpenFile: invalid download index %1 - + OpenFileInDownloadsFolder: invalid download index %1 - + get pending: invalid download index %1 - + get path: invalid download index %1 - + Main - + Update - + Optional - + Old - + Miscellaneous - + Deleted - + Archived - + Unknown - + display name: invalid download index %1 - + file name: invalid download index %1 - + file time: invalid download index %1 - + file size: invalid download index %1 - + progress: invalid download index %1 - + state: invalid download index %1 - + infocomplete: invalid download index %1 - - - + + + mod id: invalid download index %1 - + ishidden: invalid download index %1 - + file info: invalid download index %1 - + mark installed: invalid download index %1 - + mark uninstalled: invalid download index %1 - + %1% - %2 - ~%3 - + Memory allocation error (in processing progress event). - + Memory allocation error (in processing downloaded data). - + Information updated - - + + No matching file found on Nexus! Maybe this file is no longer available or it was renamed? - + No file on Nexus matches the selected file by name. Please manually choose the correct one. - + No download server available. Please try again later. - + Failed to request file info from nexus: %1 - + Warning: Content type is: %1 - + Download header content length: %1 downloaded file size: %2 - + Download failed: %1 (%2) - + We were unable to download the file due to errors after four retries. There may be an issue with the Nexus servers. - + failed to re-open %1 - + Unable to write download to drive (return %1). Check the drive's available storage. @@ -1514,12 +1524,12 @@ Canceling download "%2"... DownloadsTab - + Query Metadata - + Cannot query metadata while offline mode is enabled. Do you want to disable offline mode? @@ -3896,120 +3906,120 @@ You will have to visit the mod page on the %1 Nexus site to change your mind. - + Thank you! - + Thank you for your endorsement! - + Mod ID %1 no longer seems to be available on Nexus. - + Error %1: Request to Nexus failed: %2 - - + + failed to read %1: %2 - + Error - + failed to extract %1 (errorcode %2) - + Extract BSA - + This archive contains invalid hashes. Some files may be broken. - + Extract... - + Remove '%1' from the toolbar - + Backup of load order created - + Choose backup to restore - + This file might be left over following a crash or power loss event. Check its contents before restoring. - + No Backups - + There are no backups to restore - - + + Restore failed - - + + Failed to restore the backup. Errorcode: %1 - + Backup of mod list created - + A file with the same name has already been downloaded. What would you like to do? - + Overwrite - + Rename new file - + Ignore file @@ -4478,12 +4488,12 @@ p, li { white-space: pre-wrap; } ModInfoRegular - + %1 contains no esp/esm/esl and no asset (textures, meshes, interface, ...) directory - + Categories: <br> @@ -4584,178 +4594,198 @@ p, li { white-space: pre-wrap; } - + invalid - + installed version: "%1", newest version: "%2" - + The newest version on Nexus seems to be older than the one you have installed. This could either mean the version you have has been withdrawn (i.e. due to a bug) or the author uses a non-standard versioning scheme and that newest version is actually newer. Either way you may want to "upgrade". - + This file has been marked as "Old". There is most likely an updated version of this file available. - + This file has been marked as "Deleted"! You may want to check for an update or remove the nexus ID from this mod! - + %1 minute(s) and %2 second(s) - + This mod will be available to check in %2. - + Categories: <br> - + Invalid name - + Name is already in use by another mod - + Confirm - + Are you sure you want to remove "%1"? - + Conflicts - + Flags - + Content - + Mod Name - + Version - + Priority - + Category - + + Author + + + + + Uploader + + + + Source Game - + Nexus ID - + Installation - + Notes - - + + unknown - + Name of your mods - + Version of the mod (if available) - + Installation priority of your mod. The higher, the more "important" it is and thus overwrites files from mods with lower priority. - + Primary category of the mod. - + + Author(s) of the mod. + + + + + Uploader of the mod. This is not necessarily the same as the author. + + + + The source game which was the origin of this mod. - + Id of the mod as used on Nexus. - + Indicators of file conflicts between mods. - + Emblems to highlight things that might require attention. - + Depicts the content of the mod: - + Time this mod was installed - + User notes about the mod @@ -4852,8 +4882,8 @@ p, li { white-space: pre-wrap; } - - + + Open in Explorer @@ -4869,13 +4899,13 @@ p, li { white-space: pre-wrap; } - + Select Color... - + Reset Color @@ -4891,121 +4921,127 @@ p, li { white-space: pre-wrap; } - + Ignore missing data - + Mark as converted/working - + Visit on Nexus - - + + + Visit the uploader's profile + + + + + Visit on %1 - + Change versioning scheme - + Force-check updates - + Un-ignore update - + Ignore update - + Enable selected - + Disable selected - + Rename Mod... - + Reinstall Mod - + Remove Mod... - + Create Backup - + Restore hidden files - + Un-Endorse - - + + Endorse - + Won't endorse - + Endorsement state unknown - + Remap Category (From Nexus) - + Start tracking - + Stop tracking - + Tracked state unknown @@ -5124,7 +5160,7 @@ p, li { white-space: pre-wrap; } ModListSortProxy - + Drag&Drop is only supported when sorting by priority @@ -5153,17 +5189,17 @@ Please enter the name: - + Exception: - + Unknown exception - + <Multiple> @@ -5182,7 +5218,7 @@ Please enter the name: - + Create Mod... @@ -5194,7 +5230,7 @@ Please enter a name: - + A mod with this name already exists @@ -5226,8 +5262,8 @@ Please enter a name: - - + + Confirm @@ -5239,8 +5275,8 @@ Please enter a name: - - + + Are you sure? @@ -5317,178 +5353,197 @@ You can also use online editors and converters instead. - Nexus_ID + Mod_Author - Mod_Nexus_URL + Mod_Uploader - Mod_Version + Nexus_ID - Install_Date + Mod_Nexus_URL + Mod_Uploader_URL + + + + + Mod_Version + + + + + Install_Date + + + + Download_File_Name - + export failed: %1 - + Failed to display overwrite dialog: %1 - + Set Priority - + Set the priority of the selected mods - + failed to rename mod: %1 - + Remove the following mods?<br><ul>%1</ul> - + failed to remove mod: %1 - + Continue? - + The versioning scheme decides which version is considered newer than another. This function will guess the versioning scheme under the assumption that the installed version is outdated. - + Sorry - + I don't know a versioning scheme where %1 is newer than %2. - - Opening Nexus Links + + Nexus Links - - You are trying to open %1 links to Nexus Mods. Are you sure you want to do this? + + + Web Pages - - - Opening Web Pages + + Uploader Profiles - - - You are trying to open %1 Web Pages. Are you sure you want to do this? + + Opening %1 - - - + + You are trying to open %1 %2. Are you sure you want to do this? + + + + + + Failed - + Installation file no longer exists - + Mods installed with old versions of MO can't be reinstalled in this way. - + Failed to create backup. - + Restore all hidden files in the following mods?<br><ul>%1</ul> - + About to restore all hidden files in: - + Endorsing multiple mods will take a while. Please wait... - + Overwrite? - + This will replace the existing mod "%1". Continue? - + failed to remove mod "%1" - + failed to rename "%1" to "%2" - + Move successful. - + This will move all files from overwrite into a new, regular mod. Please enter a name: - + About to recursively delete: @@ -5510,122 +5565,80 @@ Please enter a name: NexusConnectionUI - + Connected. - + Not connected. - + Disconnected. - - Checking API key... + + Authorizing with Nexus... - - Received API key. + + Received authorization from Nexus. - + Received user account information - + + Linked with Nexus successfully. - - Failed to set API key + + Failed to store OAuth tokens. NexusInterface - + Please pick the mod ID for "%1" - + You must authorize MO2 in Settings -> Nexus to use the Nexus API. - + You've exceeded the Nexus API rate limit and requests are now being throttled. Your next batch of requests will be available in approximately %1 minutes and %2 seconds. - + Aborting download: Either you clicked on a premium-only link and your account is not premium, or the download link was generated by a different account than the one stored in Mod Organizer. - + empty response - + invalid response - - NexusManualKeyDialog - - - Manual Nexus API Key - - - - - 1. Get your personal API key - - - - - Open Browser - - - - - 2. Enter your personal API key - - - - - Paste - - - - - Clear - - - - - Enter API key here - - - - - <b>Sharing this key with anyone could get your Nexus account banned. You can always revoke this key from your account on the Nexus website.</b> - - - NexusTab @@ -5996,48 +6009,93 @@ Continue? PluginContainer - + + Plugin + + + + + Diagnose + + + + + Game + + + + + Installer + + + + + Mod Page + + + + + Preview + + + + + Tool + + + + + Proxy + + + + + File Mapper + + + + Plugin error - + Mod Organizer failed to load the plugin '%1' last time it was started. - + The plugin can be skipped for this session, blacklisted, or loaded normally, in which case it might fail again. Blacklisted plugins can be re-enabled later in the settings. - + Skip this plugin - + Blacklist this plugin - + Load this plugin - + Some plugins could not be loaded - - + + Description missing - + The following plugins could not be loaded. The reason may be missing dependencies (i.e. python) or an outdated version: @@ -6397,49 +6455,6 @@ Continue? - - PluginTypeName - - - Diagnose - - - - - Game - - - - - Installer - - - - - Mod Page - - - - - Preview - - - - - Tool - - - - - Proxy - - - - - File Mapper - - - PreviewDialog @@ -6998,7 +7013,7 @@ p, li { white-space: pre-wrap; } - + Instance type: %1 @@ -7008,193 +7023,192 @@ p, li { white-space: pre-wrap; } - + Find game installation for %1 - + Find game installation - - + + Unrecognized game - + The folder %1 does not seem to contain a game Mod Organizer can manage. - + See details for the list of supported games. - + No installation found - + Browse... - + The folder must contain a valid game installation - + Microsoft Store game - + The folder %1 seems to be a Microsoft Store game install. Games installed through the Microsoft Store are not supported by Mod Organizer and will not work properly. - - - + + + Use this folder for %1 - + Use this folder - - - + + + I know what I'm doing - - - - - + + + + - - - - - - - + + + + + + + Cancel - + The folder %1 does not seem to contain an installation for <span style="white-space: nowrap; font-weight: bold;">%2</span> or for any other game Mod Organizer can manage. - + Incorrect game - + The folder %1 seems to contain an installation for <span style="white-space: nowrap; font-weight: bold;">%2</span>, not <span style="white-space: nowrap; font-weight: bold;">%3</span>. - + Manage %1 instead - + Instance location: %1 - + Instance name: %1 - + Profile settings: - + Local INIs: %1 - - - + + + yes - - - + + + no - + Local Saves: %1 - + Automatic Archive Invalidation: %1 - - + + Base directory: %1 - + Downloads - + Mods - + Profiles - + Overwrite - + Game: %1 - + Game location: %1 @@ -7438,12 +7452,12 @@ Destination: - + Mod Organizer - + An instance of Mod Organizer is already running @@ -7515,94 +7529,114 @@ Destination: - + Connecting to Nexus... - + Waiting for Nexus... - + Opened Nexus in browser. - + Switch to your browser and accept the request. - + Finished. - - No answer from Nexus. + + An unknown error has occurred. + + + + + No OAuth client id configured. + + + + + <html><body><h2>Mod Organizer</h2><p>Authorization complete. You may close this window.</p></body></html> - - - A firewall might be blocking Mod Organizer. + + Failed to bind to localhost on port %1. - - Nexus closed the connection. + + Authorization failed (%1) - + + Internal error: OAuth flow is missing. + + + + + Invalid OAuth token payload. + + + + Cancelled. - + Failed to request %1 - - + + Cancelled - + Internal error - + HTTP code %1 - + Invalid JSON - + Bad response - - API key is empty + + + Access token is empty - + SSL error - + Timed out @@ -7675,19 +7709,19 @@ This program is known to cause issues with Mod Organizer, such as freezing or bl - - - + + + attempt to store setting for unknown plugin "%1" - + Failed - + Failed to start the helper application: %1 @@ -7734,40 +7768,33 @@ This program is known to cause issues with Mod Organizer, such as freezing or bl - - - - Enter API Key Manually - - - - - - + + + Connect to Nexus - - - - - + + + + + N/A - + Executables (*.exe) - + All Files (*.*) - + Select the browser executable @@ -7924,196 +7951,196 @@ Example: - + This error typically happens because an antivirus has deleted critical files from Mod Organizer's installation folder or has made them generally inaccessible. Add an exclusion for Mod Organizer's installation folder in your antivirus, reinstall Mod Organizer and try again. - + This error typically happens because an antivirus is preventing Mod Organizer from starting programs. Add an exclusion for Mod Organizer's installation folder in your antivirus and try again. - + The file '%1' does not exist. - + The working directory '%1' does not exist. - - - - + + + + Cannot start Steam - + The path to the Steam executable cannot be found. You might try reinstalling Steam. - - - + + + Continue without starting Steam - - + + The program may fail to launch. - + Cannot launch program - - - + + + Cannot start %1 - + Cannot launch helper - - + + Elevation required - + This program is requesting to run as administrator but Mod Organizer itself is not running as administrator. Running programs as administrator is typically unnecessary as long as the game and Mod Organizer have been installed outside "Program Files". You can restart Mod Organizer as administrator and try launching the program again. - - + + Restart Mod Organizer as administrator - - + + You must allow "helper.exe" to make changes to the system. - + Launch Steam - + This program requires Steam - + Mod Organizer has detected that this program likely requires Steam to be running to function properly. - + Start Steam - - + + The program might fail to run. - + Steam is running as administrator - + Running Steam as administrator is typically unnecessary and can cause problems when Mod Organizer itself is not running as administrator. You can restart Mod Organizer as administrator and try launching the program again. - - - + + + Continue - + Event Log not running - + The Event Log service is not running - + The Windows Event Log service is not running. This can prevent USVFS from running properly and your mods may not be recognized by the program being launched. - - + + Your mods might not work. - + Blacklisted program - + The program %1 is blacklisted - + The program you are attempting to launch is blacklisted in the virtual filesystem. This will likely prevent it from seeing any mods, INI files or any other virtualized files. - + Change the blacklist - + Waiting - + Please press OK once you're logged into steam. - + Select binary - + Binary @@ -8730,7 +8757,7 @@ If you disable this feature, MO will only display official DLCs this way. Please - + ... @@ -8847,205 +8874,195 @@ If you disable this feature, MO will only display official DLCs this way. Please - Manually enter the API key and try to login + Clear the stored Nexus authorization and force reauthorization. - Enter API Key Manually - - - - - Clear the stored Nexus API key and force reauthorization. - - - - Disconnect from Nexus - - + + Options - + Endorsement Integration - + Tracked Integration - + Use Nexus category mappings - - + + <html><head/><body><p>By default, a counter is displayed in the bottom right corner. This informs the user of their remaining API requests. The Nexus API becomes unusable once these API requests run out. Checking this option will hide that counter.</p></body></html> - + Hide API Request Counter - + Associate with "Download with manager" links - + Remove cache and cookies. - + Clear Cache - + Servers - + Known Servers (updated on download) - + Preferred Servers (Drag & Drop) - + Plugins - + Author: - + Version: - + Description: - + Enabled - + Key - + Value - + No plugin found. - + Blacklisted Plugins (use <del> to remove): - + Workarounds - + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) - + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) Uncheck this if you want to use Mod Organizer with total conversions (like Nehrim) but be aware that the game will crash if required files are not enabled. - + Force-enable game files - + Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness. - + <html><head/><body><p>By default, MO will parse archive files (BSA, BA2) to calculate conflicts between the contents of the archive files and other loose files. This process has a noticeable cost in performance.</p><p>This feature should not be confused with the archive management feature offered by MO1. MO2 will only show conflicts with archives and will NOT load them into the game or program.</p><p>If you disable this feature, MO will only display conflicts between loose files.</p></body></html> - + Enable archives parsing (experimental) - - + + Disable this to prevent the GUI from being locked when running an executable. This may result in abnormal behavior. - + Lock GUI when running executable - + Steam - + Password - + Username - + Steam App ID - + The Steam AppID for your game - + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } @@ -9061,69 +9078,69 @@ p, li { white-space: pre-wrap; } - + Network - + Disable automatic internet features - + Disable automatic internet features. This does not affect features that are explicitly invoked by the user (like checking mods for updates, endorsing, opening the web browser) - + Offline Mode - + Use a proxy for network connections. - + Use a proxy for network connections. This uses the system-wide settings which can be configured in Internet Explorer. Please note that MO will start up a few seconds slower on some systems when using a proxy. - + Use System HTTP Proxy - - + + + - - - + + Use "%1" as a placeholder for the URL. - + Custom browser - - + + Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. - + Reset Window Geometries - - + + For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. For the other games this is not a sufficient replacement for AI! @@ -9131,12 +9148,12 @@ p, li { white-space: pre-wrap; } - + Back-date BSAs - + Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended @@ -9145,64 +9162,64 @@ programs you are intentionally running. - + Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended programs may affect the execution of these programs or the programs you are intentionally running. - + Executables Blacklist - - + + Files to skip or ignore from the virtual file system. - + Skip File Suffixes - - + + Directories to skip or ignore from the virtual file system. - + Skip Directories - + These are workarounds for problems with Mod Organizer. Please make sure you read the help text before changing anything here. - + Diagnostics - + Logs and Crashes - + Log Level - + Decides the amount of data printed to "ModOrganizer.log" - + Decides the amount of data printed to "ModOrganizer.log". "Debug" produces very useful information for finding problems. There is usually no noteworthy performance impact but the file may become rather large. If this is a problem you may prefer the "Info" level for regular use. On the "Error" level the log file usually remains empty. @@ -9210,17 +9227,17 @@ programs you are intentionally running. - + Crash Dumps - + Decides which type of crash dumps are collected when injected processes crash. - + Decides which type of crash dumps are collected when injected processes crash. "None" Disables the generation of crash dumps by MO. @@ -9231,17 +9248,17 @@ programs you are intentionally running. - + Max Dumps To Keep - + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. - + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. Set "Crash Dumps" above to None to disable crash dump collection. @@ -9249,22 +9266,22 @@ programs you are intentionally running. - + Integrated LOOT - + LOOT Log Level - + Click a link to open the location - + Logs and crash dumps are stored under your current instance in the <a href="LOGS_FULL_PATH">LOGS_DIR</a> and <a href="DUMPS_FULL_PATH">DUMPS_DIR</a> folders. @@ -9325,14 +9342,6 @@ programs you are intentionally running. - - T - - - Plugin - - - TransferSavesDialog @@ -9479,7 +9488,7 @@ On Windows XP: - + Connecting to Nexus... @@ -9494,7 +9503,7 @@ On Windows XP: - + Trying again... @@ -9840,7 +9849,8 @@ Please open the "Nexus" tab. - Use this interface to obtain an API key from NexusMods. This is used for all API connections - downloads, updates etc. MO2 uses the Windows Credential Manager to store this data securely. If the SSO page on Nexus is failing, use the manual entry and copy the API key from your profile. + Use this interface to authorize Mod Organizer with Nexus Mods. This login is used for all API connections - downloads, updates etc. MO2 uses the Windows Credential Manager to store these credentials securely. + Use this interface to authorize Mod Organizer with Nexus Mods. This login is used for all Nexus API connections such as downloads and update checks. MO2 uses the Windows Credential Manager to store the resulting OAuth tokens securely. diff --git a/src/organizercore.cpp b/src/organizercore.cpp index a8fc67c27..bdbd772aa 100644 --- a/src/organizercore.cpp +++ b/src/organizercore.cpp @@ -299,11 +299,11 @@ bool OrganizerCore::nexusApi(bool retry) // previous attempt, maybe even successful return false; } else { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens)) { // credentials stored or user entered them manually log::debug("attempt to verify nexus api key"); - accessManager->apiCheck(apiKey); + accessManager->apiCheck(tokens); return true; } else { // no credentials stored and user didn't enter them @@ -1472,12 +1472,12 @@ void OrganizerCore::loggedInAction(QWidget* parent, std::function f) if (NexusInterface::instance().getAccessManager()->validated()) { f(); } else if (!m_Settings.network().offlineMode()) { - QString apiKey; - if (GlobalSettings::nexusApiKey(apiKey)) { + NexusOAuthTokens tokens; + if (GlobalSettings::nexusOAuthTokens(tokens)) { doAfterLogin([f] { f(); }); - NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + NexusInterface::instance().getAccessManager()->apiCheck(tokens); } else { MessageDialog::showMessage(tr("You need to be logged in with Nexus"), parent); } diff --git a/src/pch.h b/src/pch.h index a5bb4fb7d..5868ad199 100644 --- a/src/pch.h +++ b/src/pch.h @@ -250,7 +250,6 @@ #include #include #include -#include #include #include #include diff --git a/src/plugincontainer.cpp b/src/plugincontainer.cpp index 97c6bb09d..d70bdc728 100644 --- a/src/plugincontainer.cpp +++ b/src/plugincontainer.cpp @@ -86,47 +86,47 @@ struct PluginTypeName; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Plugin"); } + static QString value() { return PluginContainer::tr("Plugin"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Diagnose"); } + static QString value() { return PluginContainer::tr("Diagnose"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Game"); } + static QString value() { return PluginContainer::tr("Game"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Installer"); } + static QString value() { return PluginContainer::tr("Installer"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Mod Page"); } + static QString value() { return PluginContainer::tr("Mod Page"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Preview"); } + static QString value() { return PluginContainer::tr("Preview"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Tool"); } + static QString value() { return PluginContainer::tr("Tool"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("Proxy"); } + static QString value() { return PluginContainer::tr("Proxy"); } }; template <> struct PluginTypeName { - static QString value() { return QT_TR_NOOP("File Mapper"); } + static QString value() { return PluginContainer::tr("File Mapper"); } }; QStringList PluginContainer::pluginInterfaces() diff --git a/src/settings.cpp b/src/settings.cpp index ef1785b16..b1dc1623c 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -26,7 +26,9 @@ along with Mod Organizer. If not, see . #include "serverinfo.h" #include "settingsutilities.h" #include "shared/appconfig.h" +#include #include +#include #include #include @@ -2498,35 +2500,59 @@ void GlobalSettings::setHideAssignCategoriesQuestion(bool b) settings().setValue("HideAssignCategoriesQuestion", b); } -bool GlobalSettings::nexusApiKey(QString& apiKey) +namespace { - QString tempKey = getWindowsCredential("APIKEY"); - if (tempKey.isEmpty()) +constexpr auto NexusOAuthCredentialKey = "NEXUS_OAUTH_TOKENS"; + +std::optional parseStoredTokens(const QString& raw) +{ + if (raw.isEmpty()) { + return std::nullopt; + } + + const auto doc = QJsonDocument::fromJson(raw.toUtf8()); + if (doc.isNull() || !doc.isObject()) { + return std::nullopt; + } + + return NexusOAuthTokens::fromJson(doc.object()); +} +} // namespace + +bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) +{ + const auto raw = getWindowsCredential(NexusOAuthCredentialKey); + const auto parsed = parseStoredTokens(raw); + if (!parsed) { return false; + } - apiKey = tempKey; + tokens = *parsed; return true; } -bool GlobalSettings::setNexusApiKey(const QString& apiKey) +bool GlobalSettings::setNexusOAuthTokens(const NexusOAuthTokens& tokens) { - if (!setWindowsCredential("APIKEY", apiKey)) { + const auto payload = + QJsonDocument(tokens.toJson()).toJson(QJsonDocument::Compact); + + if (!setWindowsCredential(NexusOAuthCredentialKey, payload)) { const auto e = GetLastError(); - log::error("Storing API key failed: {}", formatSystemMessage(e)); + log::error("Storing OAuth tokens failed: {}", formatSystemMessage(e)); return false; } return true; } -bool GlobalSettings::clearNexusApiKey() +bool GlobalSettings::clearNexusOAuthTokens() { - return setNexusApiKey(""); + return setWindowsCredential(NexusOAuthCredentialKey, ""); } -bool GlobalSettings::hasNexusApiKey() +bool GlobalSettings::hasNexusOAuthTokens() { - return !getWindowsCredential("APIKEY").isEmpty(); + return !getWindowsCredential(NexusOAuthCredentialKey).isEmpty(); } void GlobalSettings::resetDialogs() diff --git a/src/settings.h b/src/settings.h index edc1d6445..d0cf10131 100644 --- a/src/settings.h +++ b/src/settings.h @@ -21,6 +21,7 @@ along with Mod Organizer. If not, see . #define SETTINGS_H #include "envdump.h" +#include "nexusoauthtokens.h" #include #include #include @@ -943,23 +944,23 @@ class GlobalSettings static bool hideAssignCategoriesQuestion(); static void setHideAssignCategoriesQuestion(bool b); - // if the key exists from the credentials store, puts it in `apiKey` and - // returns true; otherwise, returns false and leaves `apiKey` untouched + // Retrieves the stored OAuth tokens. Returns false if the credential doesn't exist + // or can't be parsed. // - static bool nexusApiKey(QString& apiKey); + static bool nexusOAuthTokens(NexusOAuthTokens& tokens); - // sets the api key in the credentials store, removes it if empty; returns - // false on errors + // Persists the OAuth tokens inside the credentials store, replacing the previous + // entry. Returns false on errors. // - static bool setNexusApiKey(const QString& apiKey); + static bool setNexusOAuthTokens(const NexusOAuthTokens& tokens); - // removes the api key from the credentials store; returns false on errors + // Removes the stored OAuth tokens; returns false on errors. // - static bool clearNexusApiKey(); + static bool clearNexusOAuthTokens(); - // returns whether an API key is currently stored + // Returns whether OAuth tokens are currently stored. // - static bool hasNexusApiKey(); + static bool hasNexusOAuthTokens(); // resets anything that the user can disable static void resetDialogs(); diff --git a/src/settingsdialog.ui b/src/settingsdialog.ui index 6011b1588..0eb64416b 100644 --- a/src/settingsdialog.ui +++ b/src/settingsdialog.ui @@ -1199,22 +1199,12 @@ If you disable this feature, MO will only display official DLCs this way. Please Connect to Nexus - - - - - - Manually enter the API key and try to login - - - Enter API Key Manually - - - - - + + + + - Clear the stored Nexus API key and force reauthorization. + Clear the stored Nexus authorization and force reauthorization. Disconnect from Nexus diff --git a/src/settingsdialognexus.cpp b/src/settingsdialognexus.cpp index abd487f14..b1642c0a4 100644 --- a/src/settingsdialognexus.cpp +++ b/src/settingsdialognexus.cpp @@ -1,8 +1,8 @@ #include "settingsdialognexus.h" #include "log.h" #include "nexusinterface.h" +#include "nexusoauthlogin.h" #include "serverinfo.h" -#include "ui_nexusmanualkey.h" #include "ui_settingsdialog.h" #include @@ -26,61 +26,12 @@ class ServerItem : public QListWidgetItem int m_SortRole; }; -class NexusManualKeyDialog : public QDialog -{ -public: - NexusManualKeyDialog(QWidget* parent) - : QDialog(parent), ui(new Ui::NexusManualKeyDialog) - { - ui->setupUi(this); - ui->key->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - - connect(ui->openBrowser, &QPushButton::clicked, [&] { - openBrowser(); - }); - connect(ui->paste, &QPushButton::clicked, [&] { - paste(); - }); - connect(ui->clear, &QPushButton::clicked, [&] { - clear(); - }); - } - - void accept() override - { - m_key = ui->key->toPlainText(); - QDialog::accept(); - } - - const QString& key() const { return m_key; } - - void openBrowser() - { - shell::Open(QUrl("https://www.nexusmods.com/users/myaccount?tab=api")); - } - - void paste() - { - const auto text = QApplication::clipboard()->text(); - if (!text.isEmpty()) { - ui->key->setPlainText(text); - } - } - - void clear() { ui->key->clear(); } - -private: - std::unique_ptr ui; - QString m_key; -}; - NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, QAbstractButton* connectButton, QAbstractButton* disconnectButton, - QAbstractButton* manualButton, QListWidget* logList) : m_parent(parent), m_settings(s), m_connect(connectButton), - m_disconnect(disconnectButton), m_manual(manualButton), m_log(logList) + m_disconnect(disconnectButton), m_log(logList) { if (m_connect) { QObject::connect(m_connect, &QPushButton::clicked, [&] { @@ -94,13 +45,7 @@ NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, }); } - if (m_manual) { - QObject::connect(manualButton, &QPushButton::clicked, [&] { - manual(); - }); - } - - if (GlobalSettings::hasNexusApiKey()) { + if (GlobalSettings::hasNexusOAuthTokens()) { addLog(tr("Connected.")); } else { addLog(tr("Not connected.")); @@ -117,52 +62,31 @@ void NexusConnectionUI::connect() } if (!m_nexusLogin) { - m_nexusLogin.reset(new NexusSSOLogin); + m_nexusLogin.reset(new NexusOAuthLogin(m_parent)); - m_nexusLogin->keyChanged = [&](auto&& s) { - onSSOKeyChanged(s); + m_nexusLogin->tokensReceived = [&](const NexusOAuthTokens& tokens) { + onTokensReceived(tokens); }; - m_nexusLogin->stateChanged = [&](auto&& s, auto&& e) { - onSSOStateChanged(s, e); + m_nexusLogin->stateChanged = [&](auto&& state, auto&& message) { + onOAuthStateChanged(state, message); }; } m_log->clear(); + m_pendingTokens.reset(); m_nexusLogin->start(); updateState(); } -void NexusConnectionUI::manual() -{ - if (m_nexusValidator && m_nexusValidator->isActive()) { - m_nexusValidator->cancel(); - return; - } - - NexusManualKeyDialog d(m_parent); - if (d.exec() != QDialog::Accepted) { - return; - } - - const auto key = d.key(); - if (key.isEmpty()) { - clearKey(); - return; - } - - m_log->clear(); - validateKey(key); -} - void NexusConnectionUI::disconnect() { - clearKey(); + clearTokens(); m_log->clear(); addLog(tr("Disconnected.")); } -void NexusConnectionUI::validateKey(const QString& key) +void NexusConnectionUI::validateTokens(const NexusOAuthTokens& tokens) { if (!m_nexusValidator) { m_nexusValidator.reset(new NexusKeyValidator( @@ -173,25 +97,21 @@ void NexusConnectionUI::validateKey(const QString& key) }; } - addLog(tr("Checking API key...")); - m_nexusValidator->start(key, NexusKeyValidator::OneShot); + addLog(tr("Authorizing with Nexus...")); + m_nexusValidator->start(tokens, NexusKeyValidator::OneShot); } -void NexusConnectionUI::onSSOKeyChanged(const QString& key) +void NexusConnectionUI::onTokensReceived(const NexusOAuthTokens& tokens) { - if (key.isEmpty()) { - clearKey(); - } else { - addLog(tr("Received API key.")); - validateKey(key); - } + m_pendingTokens = tokens; + addLog(tr("Received authorization from Nexus.")); + validateTokens(tokens); } -void NexusConnectionUI::onSSOStateChanged(NexusSSOLogin::States s, const QString& e) +void NexusConnectionUI::onOAuthStateChanged(NexusOAuthLogin::State s, const QString& e) { - if (s != NexusSSOLogin::Finished) { - // finished state is handled in onSSOKeyChanged() - const auto log = NexusSSOLogin::stateToString(s, e); + if (s != NexusOAuthLogin::State::Finished) { + const auto log = NexusOAuthLogin::stateToString(s, e); for (auto&& line : log.split("\n")) { addLog(line); @@ -205,14 +125,19 @@ void NexusConnectionUI::onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional user) { + Q_UNUSED(r); if (user) { NexusInterface::instance().setUserAccount(*user); addLog(tr("Received user account information")); - if (setKey(user->apiKey())) { - addLog(tr("Linked with Nexus successfully.")); + if (m_pendingTokens) { + if (persistTokens(*m_pendingTokens)) { + addLog(tr("Linked with Nexus successfully.")); + } else { + addLog(tr("Failed to store OAuth tokens.")); + } } else { - addLog(tr("Failed to set API key")); + addLog(tr("Linked with Nexus successfully.")); } } else { if (message.isEmpty()) { @@ -223,6 +148,7 @@ void NexusConnectionUI::onValidatorFinished(ValidationAttempt::Result r, } } + m_pendingTokens.reset(); updateState(); } @@ -232,9 +158,13 @@ void NexusConnectionUI::addLog(const QString& s) m_log->scrollToBottom(); } -bool NexusConnectionUI::setKey(const QString& key) +bool NexusConnectionUI::persistTokens(const NexusOAuthTokens& tokens) { - const bool ret = GlobalSettings::setNexusApiKey(key); + const bool ret = GlobalSettings::setNexusOAuthTokens(tokens); + if (ret) { + NexusInterface::instance().getAccessManager()->setTokens(tokens); + } + updateState(); emit keyChanged(); @@ -242,11 +172,11 @@ bool NexusConnectionUI::setKey(const QString& key) return ret; } -bool NexusConnectionUI::clearKey() +bool NexusConnectionUI::clearTokens() { - const auto ret = GlobalSettings::clearNexusApiKey(); + const auto ret = GlobalSettings::clearNexusOAuthTokens(); - NexusInterface::instance().getAccessManager()->clearApiKey(); + NexusInterface::instance().getAccessManager()->clearTokens(); updateState(); emit keyChanged(); @@ -266,25 +196,17 @@ void NexusConnectionUI::updateState() }; if (m_nexusLogin && m_nexusLogin->isActive()) { - // api key is in the process of being retrieved setButton(m_connect, true, QObject::tr("Cancel")); setButton(m_disconnect, false); - setButton(m_manual, false, QObject::tr("Enter API Key Manually")); } else if (m_nexusValidator && m_nexusValidator->isActive()) { - // api key is in the process of being tested setButton(m_connect, false, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); - setButton(m_manual, true, QObject::tr("Cancel")); - } else if (GlobalSettings::hasNexusApiKey()) { - // api key is present - setButton(m_connect, false, QObject::tr("Connect to Nexus")); + } else if (GlobalSettings::hasNexusOAuthTokens()) { + setButton(m_connect, true, QObject::tr("Connect to Nexus")); setButton(m_disconnect, true); - setButton(m_manual, false, QObject::tr("Enter API Key Manually")); } else { - // api key not present setButton(m_connect, true, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); - setButton(m_manual, true, QObject::tr("Enter API Key Manually")); } emit stateChanged(); @@ -325,8 +247,7 @@ NexusSettingsTab::NexusSettingsTab(Settings& s, SettingsDialog& d) : SettingsTab } m_connectionUI.reset(new NexusConnectionUI(&dialog(), &settings(), ui->nexusConnect, - ui->nexusDisconnect, ui->nexusManualKey, - ui->nexusLog)); + ui->nexusDisconnect, ui->nexusLog)); QObject::connect( m_connectionUI.get(), &NexusConnectionUI::stateChanged, &d, diff --git a/src/settingsdialognexus.h b/src/settingsdialognexus.h index 41ab207a4..a77576c21 100644 --- a/src/settingsdialognexus.h +++ b/src/settingsdialognexus.h @@ -1,10 +1,16 @@ #ifndef SETTINGSDIALOGNEXUS_H #define SETTINGSDIALOGNEXUS_H +#include "nexusoauthlogin.h" #include "nxmaccessmanager.h" +#include "nexusoauthtokens.h" #include "settings.h" #include "settingsdialog.h" +#include +#include +class QAbstractButton; +class QListWidget; // used by the settings dialog and the create instance dialog // class NexusConnectionUI : public QObject @@ -13,11 +19,9 @@ class NexusConnectionUI : public QObject public: NexusConnectionUI(QWidget* parent, Settings* s, QAbstractButton* connectButton, - QAbstractButton* disconnectButton, QAbstractButton* manualButton, - QListWidget* logList); + QAbstractButton* disconnectButton, QListWidget* logList); void connect(); - void manual(); void disconnect(); signals: @@ -29,25 +33,25 @@ class NexusConnectionUI : public QObject Settings* m_settings; QAbstractButton* m_connect; QAbstractButton* m_disconnect; - QAbstractButton* m_manual; QListWidget* m_log; - std::unique_ptr m_nexusLogin; + std::unique_ptr m_nexusLogin; std::unique_ptr m_nexusValidator; + std::optional m_pendingTokens; void addLog(const QString& s); void updateState(); - void validateKey(const QString& key); - bool setKey(const QString& key); - bool clearKey(); + void validateTokens(const NexusOAuthTokens& tokens); + bool persistTokens(const NexusOAuthTokens& tokens); + bool clearTokens(); - void onSSOKeyChanged(const QString& key); - void onSSOStateChanged(NexusSSOLogin::States s, const QString& e); + void onTokensReceived(const NexusOAuthTokens& tokens); + void onOAuthStateChanged(NexusOAuthLogin::State s, const QString& message); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, - std::optional useR); + std::optional user); }; class NexusSettingsTab : public SettingsTab diff --git a/src/spawn.cpp b/src/spawn.cpp index e9ff1276e..b82cbba48 100644 --- a/src/spawn.cpp +++ b/src/spawn.cpp @@ -37,6 +37,8 @@ along with Mod Organizer. If not, see . #include #include +class QMessageBox; + using namespace MOBase; using namespace MOShared; diff --git a/src/tutorials/tutorial_firststeps_settings.js b/src/tutorials/tutorial_firststeps_settings.js index 94ca26a3a..19926eb87 100644 --- a/src/tutorials/tutorial_firststeps_settings.js +++ b/src/tutorials/tutorial_firststeps_settings.js @@ -18,11 +18,10 @@ function getTutorialSteps() function() { highlightItem("nexusBox", false) - tutorial.text = qsTr("Use this interface to obtain an API key from NexusMods. " - +"This is used for all API connections - downloads, updates " - +"etc. MO2 uses the Windows Credential Manager to store " - +"this data securely. If the SSO page on Nexus is failing, " - +"use the manual entry and copy the API key from your profile.") + tutorial.text = qsTr("Use this interface to authorize Mod Organizer with Nexus Mods. " + +"This login is used for all API connections - downloads, updates " + +"etc. MO2 uses the Windows Credential Manager to store " + +"these credentials securely.") waitForClick() } ] From aeb0945b6304a45d9a3d24abcecf75d43dc1534e Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Fri, 17 Apr 2026 19:23:35 -0500 Subject: [PATCH 02/11] WIP: Fix fetching OAuth token TODO: - Migrate to creating or fetching MO2 API key from graphql - Convert all queries to graphql - Collections? --- src/nexusoauthlogin.cpp | 37 +++++++++++++++++++++---------------- src/nexusoauthlogin.h | 1 + src/nexusoauthtokens.h | 12 ++++++++++++ src/nxmaccessmanager.cpp | 8 +++++--- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/nexusoauthlogin.cpp b/src/nexusoauthlogin.cpp index b6a20d7e2..7f94e334f 100644 --- a/src/nexusoauthlogin.cpp +++ b/src/nexusoauthlogin.cpp @@ -93,7 +93,7 @@ void NexusOAuthLogin::start() m_flow->setTokenUrl(QUrl(NexusOAuth::tokenUrl())); #endif m_flow->setClientIdentifier(clientId); - m_flow->setScope(QString()); + m_flow->setScope("openid profile email"); #if QT_VERSION < QT_VERSION_CHECK(6, 7, 0) m_flow->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); #endif @@ -118,13 +118,13 @@ void NexusOAuthLogin::start() m_flow->setReplyHandler(m_replyHandler.get()); - QObject::connect(m_flow.get(), &QAbstractOAuth::authorizeWithBrowser, this, - [&](const QUrl& url) { + QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, + this, [&](const QUrl& url) { shell::Open(url); setState(State::WaitingForBrowser); }); - QObject::connect(m_flow.get(), &QAbstractOAuth::statusChanged, this, + QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::statusChanged, this, [&](QAbstractOAuth::Status status) { switch (status) { case QAbstractOAuth::Status::RefreshingToken: @@ -141,12 +141,14 @@ void NexusOAuthLogin::start() } }); - QObject::connect(m_flow.get(), &QAbstractOAuth::requestFailed, this, + QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, [&](QAbstractOAuth::Error error) { handleError(QObject::tr("Authorization failed (%1)").arg(int(error))); }); - QObject::connect(m_flow.get(), &QAbstractOAuth::granted, this, [&] { + QObject::connect(m_flow.get(), + &QOAuth2AuthorizationCodeFlow::granted, this, + [&]() { notifyTokens(); }); @@ -188,13 +190,14 @@ void NexusOAuthLogin::notifyTokens() return; } - QJsonObject payload; - payload.insert(QStringLiteral("access_token"), m_flow->token()); + QVariantMap payload; + payload["access_token"] = m_flow->token(); + payload["refresh_token"] = m_flow->refreshToken(); + payload["scope"] = m_flow->scope(); + payload["expiration_at"] = m_flow->expirationAt(); const auto extras = m_flow->extraTokens(); - for (auto it = extras.constBegin(); it != extras.constEnd(); ++it) { - payload.insert(it.key(), QJsonValue::fromVariant(it.value())); - } + payload.insert(extras); auto tokens = makeTokensFromResponse(payload); if (!tokens.isValid()) { @@ -253,17 +256,19 @@ void NexusOAuthLogin::injectPkceChallenge(QAbstractOAuth::Stage stage, QCryptographicHash::hash(m_codeVerifier, QCryptographicHash::Sha256) .toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); - parameters->insert(QStringLiteral("code_challenge"), QString::fromUtf8(challenge)); - parameters->insert(QStringLiteral("code_challenge_method"), QStringLiteral("S256")); - parameters->insert(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); + parameters->replace(QStringLiteral("code_challenge"), QString::fromUtf8(challenge)); + parameters->replace(QStringLiteral("code_challenge_method"), + QStringLiteral("S256")); + parameters->replace(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); break; } case QAbstractOAuth::Stage::RequestingAccessToken: { if (!m_codeVerifier.isEmpty()) { - parameters->insert(QStringLiteral("code_verifier"), QString::fromUtf8(m_codeVerifier)); + parameters->replace(QStringLiteral("code_verifier"), + QString::fromUtf8(m_codeVerifier)); } - parameters->insert(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); + parameters->replace(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); break; } diff --git a/src/nexusoauthlogin.h b/src/nexusoauthlogin.h index 6a21b7d07..8ac3cc523 100644 --- a/src/nexusoauthlogin.h +++ b/src/nexusoauthlogin.h @@ -22,6 +22,7 @@ along with Mod Organizer. If not, see . #include "nexusoauthtokens.h" #include +#include #include #include diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index 4ce719f2f..9554e4297 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -101,4 +101,16 @@ inline NexusOAuthTokens makeTokensFromResponse(const QJsonObject& json) return tokens; } +inline NexusOAuthTokens makeTokensFromResponse(const QVariantMap& data) +{ + NexusOAuthTokens tokens; + tokens.accessToken = data["access_token"].toString(); + tokens.refreshToken = data["refresh_token"].toString(); + tokens.scope = data["scope"].toString(); + tokens.tokenType = data["token_type"].toString(); + tokens.expiresAt = data["expiration_at"].toDateTime(); + + return tokens; +} + #endif // NEXUSOAUTHTOKENS_H diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 4b65139d9..222d081ee 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -44,7 +44,7 @@ along with Mod Organizer. If not, see . using namespace MOBase; using namespace std::chrono_literals; -const QString NexusBaseUrl("https://api.nexusmods.com/v1"); +const QString NexusBaseUrl("https://api.nexusmods.com/v2/graphql"); ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) @@ -181,7 +181,7 @@ void ValidationAttempt::start(NXMAccessManager& m, const NexusOAuthTokens& token bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens) { - const QString requestUrl(NexusBaseUrl + "/users/validate"); + const QString requestUrl(NexusBaseUrl); QNetworkRequest request(requestUrl); if (tokens.accessToken.isEmpty()) { @@ -190,6 +190,7 @@ bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& } const auto bearer = QStringLiteral("Bearer %1").arg(tokens.accessToken); + qDebug(tokens.accessToken.toStdString().c_str()); request.setRawHeader("Authorization", bearer.toUtf8()); request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, m.userAgent().toUtf8()); @@ -199,7 +200,8 @@ bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& request.setRawHeader("Application-Name", "MO2"); request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); - m_reply = m.get(request); + QJsonDocument data; + auto root = data.object(); if (!m_reply) { setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); From 72b2b6a3a5be18fe008103d48bd1de5325875412 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Fri, 17 Apr 2026 19:59:52 -0500 Subject: [PATCH 03/11] Migrate to users OAuth API for validation --- src/CMakeLists.txt | 3 +++ src/nexusoauthlogin.cpp | 30 +++++++++++++----------------- src/nexusoauthlogin.h | 2 +- src/nexusoauthtokens.h | 20 +++++++++----------- src/nxmaccessmanager.cpp | 34 +++++++++++++++++++++------------- src/nxmaccessmanager.h | 3 ++- src/settings.cpp | 7 +++---- src/settingsdialognexus.h | 4 ++-- 8 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 78d079d10..698ad03d5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -130,6 +130,9 @@ mo2_add_filter(NAME src/core GROUPS githubpp installationmanager nexusinterface + nexusoauthlogin + nexusoauthtokens + nexusoauthconfig nxmaccessmanager organizercore game_features diff --git a/src/nexusoauthlogin.cpp b/src/nexusoauthlogin.cpp index 7f94e334f..8b0c9bb83 100644 --- a/src/nexusoauthlogin.cpp +++ b/src/nexusoauthlogin.cpp @@ -21,14 +21,14 @@ along with Mod Organizer. If not, see . #include "nexusoauthconfig.h" #include "utility.h" #include +#include #include #include #include -#include -#include #include -#include #include +#include +#include #include #include @@ -42,9 +42,7 @@ QString callbackPath() } } // namespace -NexusOAuthLogin::NexusOAuthLogin(QObject* parent) - : QObject(parent), m_active(false) -{} +NexusOAuthLogin::NexusOAuthLogin(QObject* parent) : QObject(parent), m_active(false) {} NexusOAuthLogin::~NexusOAuthLogin() = default; @@ -102,9 +100,8 @@ void NexusOAuthLogin::start() injectPkceChallenge(stage, parameters); }); - m_replyHandler.reset( - new QOAuthHttpServerReplyHandler(QHostAddress::LocalHost, NexusOAuth::redirectPort(), - this)); + m_replyHandler.reset(new QOAuthHttpServerReplyHandler( + QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); m_replyHandler->setCallbackPath(callbackPath()); m_replyHandler->setCallbackText(QObject::tr( "

Mod Organizer

Authorization complete. You may close this " @@ -143,14 +140,13 @@ void NexusOAuthLogin::start() QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, [&](QAbstractOAuth::Error error) { - handleError(QObject::tr("Authorization failed (%1)").arg(int(error))); + handleError( + QObject::tr("Authorization failed (%1)").arg(int(error))); }); - QObject::connect(m_flow.get(), - &QOAuth2AuthorizationCodeFlow::granted, this, - [&]() { - notifyTokens(); - }); + QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::granted, this, [&]() { + notifyTokens(); + }); m_active = true; setState(State::Initializing); @@ -239,7 +235,7 @@ QByteArray randomBytes(int length) QRandomGenerator::system()->generate(bytes.begin(), bytes.end()); return bytes; } -} +} // namespace void NexusOAuthLogin::injectPkceChallenge(QAbstractOAuth::Stage stage, QMultiMap* parameters) @@ -266,7 +262,7 @@ void NexusOAuthLogin::injectPkceChallenge(QAbstractOAuth::Stage stage, case QAbstractOAuth::Stage::RequestingAccessToken: { if (!m_codeVerifier.isEmpty()) { parameters->replace(QStringLiteral("code_verifier"), - QString::fromUtf8(m_codeVerifier)); + QString::fromUtf8(m_codeVerifier)); } parameters->replace(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); break; diff --git a/src/nexusoauthlogin.h b/src/nexusoauthlogin.h index 8ac3cc523..8273df615 100644 --- a/src/nexusoauthlogin.h +++ b/src/nexusoauthlogin.h @@ -21,8 +21,8 @@ along with Mod Organizer. If not, see . #define NEXUSOAUTHLOGIN_H #include "nexusoauthtokens.h" -#include #include +#include #include #include diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index 9554e4297..377872b44 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -22,8 +22,8 @@ along with Mod Organizer. If not, see . #include #include -#include #include +#include struct NexusOAuthTokens { @@ -59,17 +59,15 @@ struct NexusOAuthTokens static std::optional fromJson(const QJsonObject& json) { NexusOAuthTokens tokens; - tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); + tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); - tokens.scope = json.value(QStringLiteral("scope")).toString(); - tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); - tokens.expiresAt = - QDateTime::fromString(json.value(QStringLiteral("expires_at")).toString(), - Qt::ISODateWithMs); + tokens.scope = json.value(QStringLiteral("scope")).toString(); + tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); + tokens.expiresAt = QDateTime::fromString( + json.value(QStringLiteral("expires_at")).toString(), Qt::ISODateWithMs); if (!tokens.expiresAt.isValid()) { - tokens.expiresAt = - QDateTime::fromString(json.value(QStringLiteral("expires_at")).toString(), - Qt::ISODate); + tokens.expiresAt = QDateTime::fromString( + json.value(QStringLiteral("expires_at")).toString(), Qt::ISODate); } if (!tokens.isValid()) { @@ -87,7 +85,7 @@ struct NexusOAuthTokens inline NexusOAuthTokens makeTokensFromResponse(const QJsonObject& json) { NexusOAuthTokens tokens; - tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); + tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); tokens.scope = json.value(QStringLiteral("scope")).toString(); tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 222d081ee..d6894500e 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -29,6 +29,7 @@ along with Mod Organizer. If not, see . #include "utility.h" #include #include +#include #include #include #include @@ -37,14 +38,13 @@ along with Mod Organizer. If not, see . #include #include #include -#include #include #include using namespace MOBase; using namespace std::chrono_literals; -const QString NexusBaseUrl("https://api.nexusmods.com/v2/graphql"); +const QString NexusBaseUrl("https://users.nexusmods.com/oauth/"); ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) @@ -182,7 +182,7 @@ void ValidationAttempt::start(NXMAccessManager& m, const NexusOAuthTokens& token bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens) { const QString requestUrl(NexusBaseUrl); - QNetworkRequest request(requestUrl); + QNetworkRequest request(requestUrl + "userinfo"); if (tokens.accessToken.isEmpty()) { setFailure(HardError, QObject::tr("Access token is empty")); @@ -200,8 +200,7 @@ bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& request.setRawHeader("Application-Name", "MO2"); request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); - QJsonDocument data; - auto root = data.object(); + m_reply = m.get(request); if (!m_reply) { setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); @@ -286,7 +285,8 @@ void ValidationAttempt::onFinished() return; } - const auto doc = QJsonDocument::fromJson(m_reply->readAll()); + const auto doc = QJsonDocument::fromJson(m_reply->readAll()); + qDebug(doc.toJson()); const auto headers = m_reply->rawHeaderPairs(); const auto httpError = m_reply->errorString(); @@ -321,14 +321,23 @@ void ValidationAttempt::onFinished() return; } - if (!data.contains("user_id")) { + if (!data.contains("sub")) { setFailure(HardError, QObject::tr("Bad response")); return; } - const int id = data.value("user_id").toInt(); - const QString name = data.value("name").toString(); - const bool premium = data.value("is_premium").toBool(); + const QString id = data.value("sub").toString(); + const QString name = data.value("name").toString(); + const auto roles = data.value("membership_roles").toArray(); + QStringList validRoles = {"premium", "lifetimepremium", "supporter"}; + bool premium = false; + for (auto role : roles) { + QString roleVal = role.toString(); + if (validRoles.contains(roleVal)) { + premium = true; + break; + } + } if (m_tokens.accessToken.isEmpty()) { setFailure(HardError, QObject::tr("Access token is empty")); @@ -341,7 +350,7 @@ void ValidationAttempt::onFinished() .id(QString("%1").arg(id)) .name(name) .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) - .limits(NexusInterface::parseLimits(headers)); + .limits(NexusInterface::defaultAPILimits()); setSuccess(user); } @@ -706,8 +715,7 @@ NXMAccessManager::refreshTokensBlocking(const NexusOAuthTokens& current) request.setRawHeader("Application-Version", MOVersion().toUtf8()); QUrlQuery formData; - formData.addQueryItem(QStringLiteral("grant_type"), - QStringLiteral("refresh_token")); + formData.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("refresh_token")); formData.addQueryItem(QStringLiteral("refresh_token"), current.refreshToken); formData.addQueryItem(QStringLiteral("client_id"), NexusOAuth::clientId()); diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index 1b051572d..62f0a2065 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -231,7 +231,8 @@ class NXMAccessManager : public QNetworkAccessManager std::optional m_tokens; void startValidationCheck(const NexusOAuthTokens& tokens); - std::optional refreshTokensBlocking(const NexusOAuthTokens& current); + std::optional + refreshTokensBlocking(const NexusOAuthTokens& current); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional); diff --git a/src/settings.cpp b/src/settings.cpp index b1dc1623c..8ee1e09a1 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -28,8 +28,8 @@ along with Mod Organizer. If not, see . #include "shared/appconfig.h" #include #include -#include #include +#include #include using namespace MOBase; @@ -2521,7 +2521,7 @@ std::optional parseStoredTokens(const QString& raw) bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) { - const auto raw = getWindowsCredential(NexusOAuthCredentialKey); + const auto raw = getWindowsCredential(NexusOAuthCredentialKey); const auto parsed = parseStoredTokens(raw); if (!parsed) { return false; @@ -2533,8 +2533,7 @@ bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) bool GlobalSettings::setNexusOAuthTokens(const NexusOAuthTokens& tokens) { - const auto payload = - QJsonDocument(tokens.toJson()).toJson(QJsonDocument::Compact); + const auto payload = QJsonDocument(tokens.toJson()).toJson(QJsonDocument::Compact); if (!setWindowsCredential(NexusOAuthCredentialKey, payload)) { const auto e = GetLastError(); diff --git a/src/settingsdialognexus.h b/src/settingsdialognexus.h index a77576c21..16e7e4382 100644 --- a/src/settingsdialognexus.h +++ b/src/settingsdialognexus.h @@ -2,12 +2,12 @@ #define SETTINGSDIALOGNEXUS_H #include "nexusoauthlogin.h" -#include "nxmaccessmanager.h" #include "nexusoauthtokens.h" +#include "nxmaccessmanager.h" #include "settings.h" #include "settingsdialog.h" -#include #include +#include class QAbstractButton; class QListWidget; From 531f73ddb5cf75a586bfd97375972fa6a156e931 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 18 Apr 2026 16:22:23 -0500 Subject: [PATCH 04/11] Allow manual fallback of old API key --- src/apiuseraccount.cpp | 13 +- src/apiuseraccount.h | 12 +- src/createinstancedialog.ui | 18 ++- src/createinstancedialogpages.cpp | 2 +- src/nexusinterface.cpp | 11 +- src/nexusmanualkey.ui | 231 ++++++++++++++++++++++++++++++ src/nexusoauthtokens.h | 8 +- src/nxmaccessmanager.cpp | 125 ++++++++++------ src/settingsdialog.ui | 18 ++- src/settingsdialognexus.cpp | 124 +++++++++++++++- src/settingsdialognexus.h | 5 +- 11 files changed, 500 insertions(+), 67 deletions(-) create mode 100644 src/nexusmanualkey.ui diff --git a/src/apiuseraccount.cpp b/src/apiuseraccount.cpp index d92c173fb..8cf868312 100644 --- a/src/apiuseraccount.cpp +++ b/src/apiuseraccount.cpp @@ -19,7 +19,7 @@ APIUserAccount::APIUserAccount() : m_type(APIUserAccountTypes::None) {} bool APIUserAccount::isValid() const { - return !m_accessToken.isEmpty(); + return !m_accessToken.isEmpty() && !m_apiKey.isEmpty(); } const QString& APIUserAccount::accessToken() const @@ -27,6 +27,11 @@ const QString& APIUserAccount::accessToken() const return m_accessToken; } +const QString& APIUserAccount::apiKey() const +{ + return m_apiKey; +} + const QString& APIUserAccount::id() const { return m_id; @@ -53,6 +58,12 @@ APIUserAccount& APIUserAccount::accessToken(const QString& token) return *this; } +APIUserAccount& APIUserAccount::apiKey(const QString& apiKey) +{ + m_apiKey = apiKey; + return *this; +} + APIUserAccount& APIUserAccount::id(const QString& id) { m_id = id; diff --git a/src/apiuseraccount.h b/src/apiuseraccount.h index 1188a67cc..628337f85 100644 --- a/src/apiuseraccount.h +++ b/src/apiuseraccount.h @@ -69,6 +69,11 @@ class APIUserAccount */ const QString& accessToken() const; + /** + * OAuth access token + */ + const QString& apiKey() const; + /** * user id */ @@ -94,6 +99,11 @@ class APIUserAccount */ APIUserAccount& accessToken(const QString& token); + /** + * sets the OAuth access token + */ + APIUserAccount& apiKey(const QString& apiKey); + /** * sets the user id */ @@ -132,7 +142,7 @@ class APIUserAccount bool exhausted() const; private: - QString m_accessToken, m_id, m_name; + QString m_accessToken, m_apiKey, m_id, m_name; APIUserAccountTypes m_type; APILimits m_limits; }; diff --git a/src/createinstancedialog.ui b/src/createinstancedialog.ui index b124b9af4..b47507559 100644 --- a/src/createinstancedialog.ui +++ b/src/createinstancedialog.ui @@ -1095,11 +1095,18 @@ 20 - - - - - + + + + + + Enter API Key Manually + + + + + + @@ -1332,6 +1339,7 @@ overwrite browseOverwrite nexusConnect + nexusManual nexusLog review creationLog diff --git a/src/createinstancedialogpages.cpp b/src/createinstancedialogpages.cpp index 4853d8a6a..778c73b7b 100644 --- a/src/createinstancedialogpages.cpp +++ b/src/createinstancedialogpages.cpp @@ -1200,7 +1200,7 @@ bool PathsPage::checkPath(QString path, PlaceholderLabel& existsLabel, NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg), m_skip(false) { m_connectionUI.reset(new NexusConnectionUI(&m_dlg, dlg.settings(), ui->nexusConnect, - nullptr, ui->nexusLog)); + nullptr, ui->nexusManual, ui->nexusLog)); // just check it once, or connecting and then going back and forth would skip // the page, which would be unexpected diff --git a/src/nexusinterface.cpp b/src/nexusinterface.cpp index 9981ab0b0..b533116b1 100644 --- a/src/nexusinterface.cpp +++ b/src/nexusinterface.cpp @@ -996,14 +996,19 @@ void NexusInterface::nextRequest() } const auto currentTokens = m_AccessManager->tokens(); - if (!currentTokens || currentTokens->accessToken.isEmpty()) { + if (!currentTokens || + (currentTokens->accessToken.isEmpty() && currentTokens->apiKey.isEmpty())) { log::error("nexus: no OAuth token available, request aborted"); info.m_Reply = nullptr; return; } - const auto bearer = QStringLiteral("Bearer %1").arg(currentTokens->accessToken); - request.setRawHeader("Authorization", bearer.toUtf8()); + if (!currentTokens->accessToken.isEmpty()) { + const auto bearer = QStringLiteral("Bearer %1").arg(currentTokens->accessToken); + request.setRawHeader("Authorization", bearer.toUtf8()); + } else { + request.setRawHeader("APIKEY", currentTokens->apiKey.toUtf8()); + } request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, m_AccessManager->userAgent(info.m_SubModule)); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, diff --git a/src/nexusmanualkey.ui b/src/nexusmanualkey.ui new file mode 100644 index 000000000..847bdd61d --- /dev/null +++ b/src/nexusmanualkey.ui @@ -0,0 +1,231 @@ + + + NexusManualKeyDialog + + + + 0 + 0 + 452 + 236 + + + + Manual Nexus API Key + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 1. Get your personal API key + + + + + + + Open Browser + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 2. Enter your personal API key + + + + + + + Qt::Horizontal + + + + 115 + 20 + + + + + + + + Paste + + + + + + + Clear + + + + + + + + + + + 0 + 0 + + + + + 1 + 1 + + + + Enter API key here + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <b>Sharing this key with anyone could get your Nexus account banned. You can always revoke this key from your account on the Nexus website.</b> + + + true + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + NexusManualKeyDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + NexusManualKeyDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index 377872b44..21c1d5008 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -32,8 +32,12 @@ struct NexusOAuthTokens QString scope; QString tokenType; QDateTime expiresAt; + QString apiKey; - bool isValid() const { return !accessToken.isEmpty() && expiresAt.isValid(); } + bool isValid() const + { + return (!accessToken.isEmpty() && expiresAt.isValid()) || !apiKey.isEmpty(); + } bool isExpired(std::chrono::seconds skew = std::chrono::seconds(60)) const { @@ -48,6 +52,7 @@ struct NexusOAuthTokens QJsonObject toJson() const { QJsonObject json; + json.insert(QStringLiteral("api_key"), apiKey); json.insert(QStringLiteral("access_token"), accessToken); json.insert(QStringLiteral("refresh_token"), refreshToken); json.insert(QStringLiteral("scope"), scope); @@ -59,6 +64,7 @@ struct NexusOAuthTokens static std::optional fromJson(const QJsonObject& json) { NexusOAuthTokens tokens; + tokens.apiKey = json.value(QStringLiteral("api_key")).toString(); tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); tokens.scope = json.value(QStringLiteral("scope")).toString(); diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index d6894500e..fa8758696 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -44,7 +44,8 @@ along with Mod Organizer. If not, see . using namespace MOBase; using namespace std::chrono_literals; -const QString NexusBaseUrl("https://users.nexusmods.com/oauth/"); +const QString NexusUserUrl("https://users.nexusmods.com/oauth/"); +const QString NexusV1BaseUrl("https://api.nexusmods.com/v1/"); ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) @@ -181,17 +182,24 @@ void ValidationAttempt::start(NXMAccessManager& m, const NexusOAuthTokens& token bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens) { - const QString requestUrl(NexusBaseUrl); - QNetworkRequest request(requestUrl + "userinfo"); - if (tokens.accessToken.isEmpty()) { - setFailure(HardError, QObject::tr("Access token is empty")); + if (tokens.accessToken.isEmpty() && tokens.apiKey.isEmpty()) { + setFailure(HardError, QObject::tr("No access token or API key")); return false; } - const auto bearer = QStringLiteral("Bearer %1").arg(tokens.accessToken); - qDebug(tokens.accessToken.toStdString().c_str()); - request.setRawHeader("Authorization", bearer.toUtf8()); + QString requestUrl; + QNetworkRequest request; + if (!tokens.accessToken.isEmpty()) { + requestUrl = NexusUserUrl + "userinfo"; + request.setUrl(requestUrl); + const auto bearer = QStringLiteral("Bearer %1").arg(tokens.accessToken); + request.setRawHeader("Authorization", bearer.toUtf8()); + } else { + requestUrl = NexusV1BaseUrl + "users/validate"; + request.setUrl(requestUrl); + request.setRawHeader("APIKEY", tokens.apiKey.toUtf8()); + } request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, m.userAgent().toUtf8()); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, @@ -285,8 +293,7 @@ void ValidationAttempt::onFinished() return; } - const auto doc = QJsonDocument::fromJson(m_reply->readAll()); - qDebug(doc.toJson()); + const auto doc = QJsonDocument::fromJson(m_reply->readAll()); const auto headers = m_reply->rawHeaderPairs(); const auto httpError = m_reply->errorString(); @@ -321,38 +328,60 @@ void ValidationAttempt::onFinished() return; } - if (!data.contains("sub")) { - setFailure(HardError, QObject::tr("Bad response")); - return; - } + if (!m_tokens.accessToken.isEmpty()) { + if (!data.contains("sub")) { + setFailure(HardError, QObject::tr("Bad response")); + return; + } - const QString id = data.value("sub").toString(); - const QString name = data.value("name").toString(); - const auto roles = data.value("membership_roles").toArray(); - QStringList validRoles = {"premium", "lifetimepremium", "supporter"}; - bool premium = false; - for (auto role : roles) { - QString roleVal = role.toString(); - if (validRoles.contains(roleVal)) { - premium = true; - break; + const QString id = data.value("sub").toString(); + const QString name = data.value("name").toString(); + const auto roles = data.value("membership_roles").toArray(); + QStringList validRoles = {"premium", "lifetimepremium", "supporter"}; + bool premium = false; + for (auto role : roles) { + QString roleVal = role.toString(); + if (validRoles.contains(roleVal)) { + premium = true; + break; + } } - } - if (m_tokens.accessToken.isEmpty()) { - setFailure(HardError, QObject::tr("Access token is empty")); - return; - } + if (m_tokens.accessToken.isEmpty()) { + setFailure(HardError, QObject::tr("Access token is empty")); + return; + } - const auto user = - APIUserAccount() - .accessToken(m_tokens.accessToken) - .id(QString("%1").arg(id)) - .name(name) - .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) - .limits(NexusInterface::defaultAPILimits()); + const auto user = + APIUserAccount() + .accessToken(m_tokens.accessToken) + .id(QString("%1").arg(id)) + .name(name) + .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) + .limits(NexusInterface::defaultAPILimits()); + + setSuccess(user); + } else if (!m_tokens.apiKey.isEmpty()) { + if (!data.contains("user_id")) { + setFailure(HardError, QObject::tr("Bad response")); + return; + } - setSuccess(user); + const int id = data.value("user_id").toInt(); + const QString key = data.value("key").toString(); + const QString name = data.value("name").toString(); + const bool premium = data.value("is_premium").toBool(); + + const auto user = + APIUserAccount() + .apiKey(m_tokens.apiKey) + .id(QString("%1").arg(id)) + .name(name) + .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) + .limits(NexusInterface::parseLimits(headers)); + + setSuccess(user); + } } void ValidationAttempt::onSslErrors(const QList& errors) @@ -637,7 +666,7 @@ NXMAccessManager::createRequest(QNetworkAccessManager::Operation operation, void NXMAccessManager::showCookies() const { - QUrl url(NexusBaseUrl + "/"); + QUrl url(NexusV1BaseUrl + "/"); for (const QNetworkCookie& cookie : cookieJar()->cookiesForUrl(url)) { log::debug("{} - {} (expires: {})", cookie.name().constData(), cookie.value().constData(), cookie.expirationDate().toString()); @@ -671,17 +700,21 @@ bool NXMAccessManager::ensureFreshToken() return false; } - if (!m_tokens->isExpired()) { - return true; - } + if (!m_tokens->accessToken.isEmpty()) { + if (!m_tokens->isExpired()) { + return true; + } - const auto refreshed = refreshTokensBlocking(*m_tokens); - if (!refreshed) { - return false; + const auto refreshed = refreshTokensBlocking(*m_tokens); + if (!refreshed) { + return false; + } + + setTokens(*refreshed); + GlobalSettings::setNexusOAuthTokens(*refreshed); + return true; } - setTokens(*refreshed); - GlobalSettings::setNexusOAuthTokens(*refreshed); return true; } diff --git a/src/settingsdialog.ui b/src/settingsdialog.ui index 0eb64416b..49bef6dc2 100644 --- a/src/settingsdialog.ui +++ b/src/settingsdialog.ui @@ -1199,10 +1199,20 @@ If you disable this feature, MO will only display official DLCs this way. Please Connect to Nexus - - - - + + + + + + Manually enter the API key and try to login + + + Enter API Key Manually + + + + + Clear the stored Nexus authorization and force reauthorization. diff --git a/src/settingsdialognexus.cpp b/src/settingsdialognexus.cpp index b1642c0a4..9b4cf146a 100644 --- a/src/settingsdialognexus.cpp +++ b/src/settingsdialognexus.cpp @@ -3,6 +3,7 @@ #include "nexusinterface.h" #include "nexusoauthlogin.h" #include "serverinfo.h" +#include "ui_nexusmanualkey.h" #include "ui_settingsdialog.h" #include @@ -26,12 +27,61 @@ class ServerItem : public QListWidgetItem int m_SortRole; }; +class NexusManualKeyDialog : public QDialog +{ +public: + NexusManualKeyDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::NexusManualKeyDialog) + { + ui->setupUi(this); + ui->key->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + + connect(ui->openBrowser, &QPushButton::clicked, [&] { + openBrowser(); + }); + connect(ui->paste, &QPushButton::clicked, [&] { + paste(); + }); + connect(ui->clear, &QPushButton::clicked, [&] { + clear(); + }); + } + + void accept() override + { + m_key = ui->key->toPlainText(); + QDialog::accept(); + } + + const QString& key() const { return m_key; } + + void openBrowser() + { + shell::Open(QUrl("https://www.nexusmods.com/users/myaccount?tab=api")); + } + + void paste() + { + const auto text = QApplication::clipboard()->text(); + if (!text.isEmpty()) { + ui->key->setPlainText(text); + } + } + + void clear() { ui->key->clear(); } + +private: + std::unique_ptr ui; + QString m_key; +}; + NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, QAbstractButton* connectButton, QAbstractButton* disconnectButton, + QAbstractButton* manualButton, QListWidget* logList) : m_parent(parent), m_settings(s), m_connect(connectButton), - m_disconnect(disconnectButton), m_log(logList) + m_disconnect(disconnectButton), m_manual(manualButton), m_log(logList) { if (m_connect) { QObject::connect(m_connect, &QPushButton::clicked, [&] { @@ -45,6 +95,12 @@ NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, }); } + if (m_manual) { + QObject::connect(manualButton, &QPushButton::clicked, [&] { + manual(); + }); + } + if (GlobalSettings::hasNexusOAuthTokens()) { addLog(tr("Connected.")); } else { @@ -79,6 +135,35 @@ void NexusConnectionUI::connect() updateState(); } +void NexusConnectionUI::manual() +{ + if (m_nexusValidator && m_nexusValidator->isActive()) { + m_nexusValidator->cancel(); + return; + } + + NexusManualKeyDialog d(m_parent); + if (d.exec() != QDialog::Accepted) { + return; + } + + const auto key = d.key(); + if (key.isEmpty()) { + clearTokens(); + return; + } + + m_log->clear(); + auto tokens = NexusInterface::instance().getAccessManager()->tokens(); + if (!tokens) { + tokens = NexusOAuthTokens(); + } + tokens->apiKey = key; + NexusInterface::instance().getAccessManager()->setTokens(*tokens); + m_pendingTokens = tokens; + validateTokens(*tokens); +} + void NexusConnectionUI::disconnect() { clearTokens(); @@ -103,7 +188,24 @@ void NexusConnectionUI::validateTokens(const NexusOAuthTokens& tokens) void NexusConnectionUI::onTokensReceived(const NexusOAuthTokens& tokens) { - m_pendingTokens = tokens; + if (GlobalSettings::hasNexusOAuthTokens()) { + NexusOAuthTokens oldTokens; + GlobalSettings::nexusOAuthTokens(oldTokens); + NexusOAuthTokens newTokens(tokens); + if (tokens.apiKey.isEmpty()) { + newTokens.apiKey = oldTokens.apiKey; + } + if (tokens.accessToken.isEmpty()) { + newTokens.accessToken = oldTokens.accessToken; + newTokens.refreshToken = oldTokens.refreshToken; + newTokens.tokenType = oldTokens.tokenType; + newTokens.expiresAt = oldTokens.expiresAt; + newTokens.scope = oldTokens.scope; + } + m_pendingTokens = newTokens; + } else { + m_pendingTokens = tokens; + } addLog(tr("Received authorization from Nexus.")); validateTokens(tokens); } @@ -198,15 +300,28 @@ void NexusConnectionUI::updateState() if (m_nexusLogin && m_nexusLogin->isActive()) { setButton(m_connect, true, QObject::tr("Cancel")); setButton(m_disconnect, false); + setButton(m_manual, false, QObject::tr("Enter API Key Manually")); } else if (m_nexusValidator && m_nexusValidator->isActive()) { setButton(m_connect, false, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); } else if (GlobalSettings::hasNexusOAuthTokens()) { - setButton(m_connect, true, QObject::tr("Connect to Nexus")); + NexusOAuthTokens tokens; + GlobalSettings::nexusOAuthTokens(tokens); + if (tokens.accessToken.isEmpty()) { + setButton(m_connect, true, QObject::tr("Connect to Nexus")); + } else { + setButton(m_connect, false, QObject::tr("Connect to Nexus")); + } setButton(m_disconnect, true); + if (tokens.apiKey.isEmpty()) { + setButton(m_manual, true, QObject::tr("Enter API Key Manually")); + } else { + setButton(m_manual, false, QObject::tr("Enter API Key Manually")); + } } else { setButton(m_connect, true, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); + setButton(m_manual, true, QObject::tr("Enter API Key Manually")); } emit stateChanged(); @@ -247,7 +362,8 @@ NexusSettingsTab::NexusSettingsTab(Settings& s, SettingsDialog& d) : SettingsTab } m_connectionUI.reset(new NexusConnectionUI(&dialog(), &settings(), ui->nexusConnect, - ui->nexusDisconnect, ui->nexusLog)); + ui->nexusDisconnect, ui->nexusManualKey, + ui->nexusLog)); QObject::connect( m_connectionUI.get(), &NexusConnectionUI::stateChanged, &d, diff --git a/src/settingsdialognexus.h b/src/settingsdialognexus.h index 16e7e4382..df3bd3cec 100644 --- a/src/settingsdialognexus.h +++ b/src/settingsdialognexus.h @@ -19,9 +19,11 @@ class NexusConnectionUI : public QObject public: NexusConnectionUI(QWidget* parent, Settings* s, QAbstractButton* connectButton, - QAbstractButton* disconnectButton, QListWidget* logList); + QAbstractButton* disconnectButton, QAbstractButton* manualButton, + QListWidget* logList); void connect(); + void manual(); void disconnect(); signals: @@ -33,6 +35,7 @@ class NexusConnectionUI : public QObject Settings* m_settings; QAbstractButton* m_connect; QAbstractButton* m_disconnect; + QAbstractButton* m_manual; QListWidget* m_log; std::unique_ptr m_nexusLogin; From f3edd764b4cd082c0d25ba4c5a0220540d3d67b3 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 18 Apr 2026 20:09:22 -0500 Subject: [PATCH 05/11] Use (and clear) old credential store if set --- src/settings.cpp | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/settings.cpp b/src/settings.cpp index 8ee1e09a1..add8eef23 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -2502,7 +2502,8 @@ void GlobalSettings::setHideAssignCategoriesQuestion(bool b) namespace { -constexpr auto NexusOAuthCredentialKey = "NEXUS_OAUTH_TOKENS"; +constexpr auto NexusLegacyCredentialKey = "APIKEY"; +constexpr auto NexusOAuthCredentialKey = "NEXUS_OAUTH_TOKENS"; std::optional parseStoredTokens(const QString& raw) { @@ -2521,13 +2522,34 @@ std::optional parseStoredTokens(const QString& raw) bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) { + // If legacy credential key exists and is not set in the new credentials, + // insert it into the current tokens. In all cases, clear the old credential + // store once parsed. + const auto legacyRaw = getWindowsCredential(NexusLegacyCredentialKey); + if (!legacyRaw.isEmpty()) { + tokens.apiKey = legacyRaw; + setWindowsCredential(NexusLegacyCredentialKey, ""); + } const auto raw = getWindowsCredential(NexusOAuthCredentialKey); const auto parsed = parseStoredTokens(raw); - if (!parsed) { + if (!parsed && legacyRaw.isEmpty()) { return false; + } else if (parsed && legacyRaw.isEmpty()) { + tokens = *parsed; + } else if (parsed) { + if (!parsed->apiKey.isEmpty()) { + tokens = *parsed; + } else { + tokens.accessToken = parsed->accessToken; + tokens.refreshToken = parsed->refreshToken; + tokens.expiresAt = parsed->expiresAt; + tokens.tokenType = parsed->tokenType; + tokens.scope = parsed->scope; + setNexusOAuthTokens(tokens); + } + } else { + setNexusOAuthTokens(tokens); } - - tokens = *parsed; return true; } From 87135c15de27a40daecca9dac4a7cd6d69b45fe5 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Mon, 20 Apr 2026 12:53:14 -0500 Subject: [PATCH 06/11] Refactor OAuth auth and requests - Moved all OAuth code to NXMAccessManager - Removed unnecessary manual auth / refresh code - Ensured we initialize / refresh token on startup - Use OAuth flow to call API endpoints --- src/apiuseraccount.cpp | 2 +- src/instancemanager.cpp | 6 +- src/nexusinterface.cpp | 76 +++--- src/nexusoauthlogin.cpp | 219 +-------------- src/nexusoauthlogin.h | 22 +- src/nxmaccessmanager.cpp | 516 ++++++++++++++++++++++++------------ src/nxmaccessmanager.h | 71 +++-- src/settingsdialognexus.cpp | 7 +- src/settingsdialognexus.h | 2 +- 9 files changed, 463 insertions(+), 458 deletions(-) diff --git a/src/apiuseraccount.cpp b/src/apiuseraccount.cpp index 8cf868312..e9785b404 100644 --- a/src/apiuseraccount.cpp +++ b/src/apiuseraccount.cpp @@ -19,7 +19,7 @@ APIUserAccount::APIUserAccount() : m_type(APIUserAccountTypes::None) {} bool APIUserAccount::isValid() const { - return !m_accessToken.isEmpty() && !m_apiKey.isEmpty(); + return !m_accessToken.isEmpty() || !m_apiKey.isEmpty(); } const QString& APIUserAccount::accessToken() const diff --git a/src/instancemanager.cpp b/src/instancemanager.cpp index fcb7bf676..54da7f9ac 100644 --- a/src/instancemanager.cpp +++ b/src/instancemanager.cpp @@ -53,8 +53,10 @@ QString Instance::displayName() const { if (isPortable()) return QObject::tr("Portable"); - else - return QDir(m_dir).dirName(); + else { + QDir instanceDir(m_dir.toUtf8()); + return instanceDir.dirName(); + } } QString Instance::gameName() const diff --git a/src/nexusinterface.cpp b/src/nexusinterface.cpp index b533116b1..1320fdfd0 100644 --- a/src/nexusinterface.cpp +++ b/src/nexusinterface.cpp @@ -985,15 +985,11 @@ void NexusInterface::nextRequest() } else { url = info.m_URL; } - QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, - QNetworkRequest::AlwaysNetwork); - if (!m_AccessManager->ensureFreshToken()) { - log::error("nexus: unable to refresh OAuth token, request aborted"); - info.m_Reply = nullptr; - return; - } + // if (!m_AccessManager->ensureFreshToken()) { + // log::error("nexus: unable to refresh OAuth token, request aborted"); + // info.m_Reply = nullptr; + // return; + // } const auto currentTokens = m_AccessManager->tokens(); if (!currentTokens || @@ -1003,34 +999,50 @@ void NexusInterface::nextRequest() return; } + QNetworkRequest request(url); if (!currentTokens->accessToken.isEmpty()) { - const auto bearer = QStringLiteral("Bearer %1").arg(currentTokens->accessToken); - request.setRawHeader("Authorization", bearer.toUtf8()); + if (postData.object().isEmpty()) { + if (!requestIsDelete) { + info.m_Reply = m_AccessManager->makeOAuthGetRequest(url); + } else { + info.m_Reply = m_AccessManager->deleteResource(request); + } + } else if (!requestIsDelete) { + info.m_Reply = m_AccessManager->makeOAuthPostRequest(url, postData.toJson()); + } else { + // Qt doesn't support DELETE with a payload as that's technically against the HTTP + // standard... + info.m_Reply = + m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); + } } else { + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); request.setRawHeader("APIKEY", currentTokens->apiKey.toUtf8()); - } - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m_AccessManager->userAgent(info.m_SubModule)); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", - QApplication::applicationVersion().toUtf8()); - - if (postData.object().isEmpty()) { - if (!requestIsDelete) { - info.m_Reply = m_AccessManager->get(request); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + m_AccessManager->userAgent(info.m_SubModule)); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", + QApplication::applicationVersion().toUtf8()); + + if (postData.object().isEmpty()) { + if (!requestIsDelete) { + info.m_Reply = m_AccessManager->get(request); + } else { + info.m_Reply = m_AccessManager->deleteResource(request); + } + } else if (!requestIsDelete) { + info.m_Reply = m_AccessManager->post(request, postData.toJson()); } else { - info.m_Reply = m_AccessManager->deleteResource(request); + // Qt doesn't support DELETE with a payload as that's technically against the HTTP + // standard... + info.m_Reply = + m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); } - } else if (!requestIsDelete) { - info.m_Reply = m_AccessManager->post(request, postData.toJson()); - } else { - // Qt doesn't support DELETE with a payload as that's technically against the HTTP - // standard... - info.m_Reply = - m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); } connect(info.m_Reply, SIGNAL(finished()), this, SLOT(requestFinished())); diff --git a/src/nexusoauthlogin.cpp b/src/nexusoauthlogin.cpp index 8b0c9bb83..03f14198e 100644 --- a/src/nexusoauthlogin.cpp +++ b/src/nexusoauthlogin.cpp @@ -18,7 +18,9 @@ along with Mod Organizer. If not, see . */ #include "nexusoauthlogin.h" +#include "nexusinterface.h" #include "nexusoauthconfig.h" +#include "nxmaccessmanager.h" #include "utility.h" #include #include @@ -46,229 +48,34 @@ NexusOAuthLogin::NexusOAuthLogin(QObject* parent) : QObject(parent), m_active(fa NexusOAuthLogin::~NexusOAuthLogin() = default; -QString NexusOAuthLogin::stateToString(State state, const QString& details) -{ - switch (state) { - case State::Initializing: - return QObject::tr("Connecting to Nexus..."); - - case State::WaitingForBrowser: - return QObject::tr("Opened Nexus in browser.") + "\n" + - QObject::tr("Switch to your browser and accept the request."); - - case State::Authorizing: - return QObject::tr("Waiting for Nexus..."); - - case State::Finished: - return QObject::tr("Finished."); - - case State::Cancelled: - return QObject::tr("Cancelled."); - - case State::Error: - default: - return details.isEmpty() ? QObject::tr("An unknown error has occurred.") : details; - } -} - void NexusOAuthLogin::start() { if (m_active) { cancel(); } + auto accessManager = NexusInterface::instance().getAccessManager(); + connect(accessManager, &NXMAccessManager::authorizationEnded, this, + &NexusOAuthLogin::authorizationEnded); - const auto clientId = NexusOAuth::clientId(); - if (clientId.isEmpty()) { - handleError(QObject::tr("No OAuth client id configured.")); - return; - } - - m_flow.reset(new QOAuth2AuthorizationCodeFlow); - m_flow->setAuthorizationUrl(QUrl(NexusOAuth::authorizeUrl())); -#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) - m_flow->setAccessTokenUrl(QUrl(NexusOAuth::tokenUrl())); -#else - m_flow->setTokenUrl(QUrl(NexusOAuth::tokenUrl())); -#endif - m_flow->setClientIdentifier(clientId); - m_flow->setScope("openid profile email"); -#if QT_VERSION < QT_VERSION_CHECK(6, 7, 0) - m_flow->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); -#endif - m_flow->setModifyParametersFunction( - [this](QAbstractOAuth::Stage stage, QMultiMap* parameters) { - injectPkceChallenge(stage, parameters); - }); - - m_replyHandler.reset(new QOAuthHttpServerReplyHandler( - QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); - m_replyHandler->setCallbackPath(callbackPath()); - m_replyHandler->setCallbackText(QObject::tr( - "

Mod Organizer

Authorization complete. You may close this " - "window.

")); - if (!m_replyHandler->isListening() && - !m_replyHandler->listen(QHostAddress::LocalHost, NexusOAuth::redirectPort())) { - handleError(QObject::tr("Failed to bind to localhost on port %1.") - .arg(NexusOAuth::redirectPort())); - return; - } - - m_flow->setReplyHandler(m_replyHandler.get()); - - QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, - this, [&](const QUrl& url) { - shell::Open(url); - setState(State::WaitingForBrowser); - }); - - QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::statusChanged, this, - [&](QAbstractOAuth::Status status) { - switch (status) { - case QAbstractOAuth::Status::RefreshingToken: - case QAbstractOAuth::Status::TemporaryCredentialsReceived: - setState(State::Authorizing); - break; - - case QAbstractOAuth::Status::Granted: - setState(State::Finished); - break; - - default: - break; - } - }); - - QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, - [&](QAbstractOAuth::Error error) { - handleError( - QObject::tr("Authorization failed (%1)").arg(int(error))); - }); - - QObject::connect(m_flow.get(), &QOAuth2AuthorizationCodeFlow::granted, this, [&]() { - notifyTokens(); - }); + accessManager->tokensReceived = tokensReceived; + accessManager->stateChanged = stateChanged; + NexusInterface::instance().getAccessManager()->connectOrRefresh(NexusOAuthTokens()); m_active = true; - setState(State::Initializing); - m_flow->grant(); -} - -void NexusOAuthLogin::cancel() -{ - if (m_replyHandler) { - m_replyHandler->close(); - } - - m_flow.reset(); - m_replyHandler.reset(); - if (m_active) { - m_active = false; - setState(State::Cancelled); - } -} - -bool NexusOAuthLogin::isActive() const -{ - return m_active; -} - -void NexusOAuthLogin::setState(State state, const QString& message) -{ - if (stateChanged) { - stateChanged(state, message); - } } -void NexusOAuthLogin::notifyTokens() +void NexusOAuthLogin::authorizationEnded() { - if (!m_flow) { - handleError(QObject::tr("Internal error: OAuth flow is missing.")); - return; - } - - QVariantMap payload; - payload["access_token"] = m_flow->token(); - payload["refresh_token"] = m_flow->refreshToken(); - payload["scope"] = m_flow->scope(); - payload["expiration_at"] = m_flow->expirationAt(); - - const auto extras = m_flow->extraTokens(); - payload.insert(extras); - - auto tokens = makeTokensFromResponse(payload); - if (!tokens.isValid()) { - handleError(QObject::tr("Invalid OAuth token payload.")); - return; - } - - tokens.scope = m_flow->scope(); - - m_flow.reset(); - m_replyHandler.reset(); - m_codeVerifier.clear(); m_active = false; - if (tokensReceived) { - tokensReceived(tokens); - } } -void NexusOAuthLogin::handleError(const QString& message) +void NexusOAuthLogin::cancel() { - if (m_replyHandler) { - m_replyHandler->close(); - } - m_flow.reset(); - m_replyHandler.reset(); - m_codeVerifier.clear(); + NexusInterface::instance().getAccessManager()->cancelAuth(); m_active = false; - if (stateChanged) { - stateChanged(State::Error, message); - } } -namespace -{ -QByteArray randomBytes(int length) -{ - QByteArray bytes; - bytes.resize(length); - QRandomGenerator::system()->generate(bytes.begin(), bytes.end()); - return bytes; -} -} // namespace - -void NexusOAuthLogin::injectPkceChallenge(QAbstractOAuth::Stage stage, - QMultiMap* parameters) +bool NexusOAuthLogin::isActive() const { - if (!parameters) { - return; - } - - switch (stage) { - case QAbstractOAuth::Stage::RequestingAuthorization: { - m_codeVerifier = randomBytes(32).toBase64(QByteArray::Base64UrlEncoding | - QByteArray::OmitTrailingEquals); - const auto challenge = - QCryptographicHash::hash(m_codeVerifier, QCryptographicHash::Sha256) - .toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); - - parameters->replace(QStringLiteral("code_challenge"), QString::fromUtf8(challenge)); - parameters->replace(QStringLiteral("code_challenge_method"), - QStringLiteral("S256")); - parameters->replace(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); - break; - } - - case QAbstractOAuth::Stage::RequestingAccessToken: { - if (!m_codeVerifier.isEmpty()) { - parameters->replace(QStringLiteral("code_verifier"), - QString::fromUtf8(m_codeVerifier)); - } - parameters->replace(QStringLiteral("redirect_uri"), NexusOAuth::redirectUri()); - break; - } - - default: - break; - } + return m_active; } diff --git a/src/nexusoauthlogin.h b/src/nexusoauthlogin.h index 8273df615..66b6b392e 100644 --- a/src/nexusoauthlogin.h +++ b/src/nexusoauthlogin.h @@ -21,6 +21,7 @@ along with Mod Organizer. If not, see . #define NEXUSOAUTHLOGIN_H #include "nexusoauthtokens.h" +#include "nxmaccessmanager.h" #include #include #include @@ -34,16 +35,6 @@ class NexusOAuthLogin : public QObject Q_OBJECT public: - enum class State - { - Initializing, - WaitingForBrowser, - Authorizing, - Finished, - Cancelled, - Error - }; - explicit NexusOAuthLogin(QObject* parent = nullptr); ~NexusOAuthLogin(); @@ -52,21 +43,16 @@ class NexusOAuthLogin : public QObject bool isActive() const; std::function tokensReceived; - std::function stateChanged; + std::function stateChanged; - static QString stateToString(State state, const QString& details = {}); +private slots: + void authorizationEnded(); private: std::unique_ptr m_flow; std::unique_ptr m_replyHandler; bool m_active; - void setState(State state, const QString& message = {}); - void notifyTokens(); - void handleError(const QString& message); - void injectPkceChallenge(QAbstractOAuth::Stage stage, - QMultiMap* parameters); - QByteArray m_codeVerifier; }; diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index fa8758696..60c97a511 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -21,6 +21,7 @@ along with Mod Organizer. If not, see . #include "iplugingame.h" #include "nexusinterface.h" #include "nexusoauthconfig.h" +#include "nexusoauthlogin.h" #include "nxmurl.h" #include "persistentcookiejar.h" #include "report.h" @@ -48,7 +49,7 @@ const QString NexusUserUrl("https://users.nexusmods.com/oauth/"); const QString NexusV1BaseUrl("https://api.nexusmods.com/v1/"); ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) - : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) + : m_Settings(s), m_Validator(v), m_UpdateTimer(nullptr), m_First(true) { ui.reset(new Ui::ValidationProgressDialog); ui->setupUi(this); @@ -77,24 +78,24 @@ void ValidationProgressDialog::setParentWidget(QWidget* w) void ValidationProgressDialog::start() { - if (!m_updateTimer) { - m_updateTimer = new QTimer(this); - connect(m_updateTimer, &QTimer::timeout, [&] { + if (!m_UpdateTimer) { + m_UpdateTimer = new QTimer(this); + connect(m_UpdateTimer, &QTimer::timeout, [&] { onTimer(); }); - m_updateTimer->setInterval(100ms); + m_UpdateTimer->setInterval(100ms); } updateProgress(); - m_updateTimer->start(); + m_UpdateTimer->start(); show(); } void ValidationProgressDialog::stop() { - if (m_updateTimer) { - m_updateTimer->stop(); + if (m_UpdateTimer) { + m_UpdateTimer->stop(); } hide(); @@ -102,12 +103,12 @@ void ValidationProgressDialog::stop() void ValidationProgressDialog::showEvent(QShowEvent* e) { - if (m_first) { - if (m_settings) { - m_settings->geometry().centerOnMainWindowMonitor(this); + if (m_First) { + if (m_Settings) { + m_Settings->geometry().centerOnMainWindowMonitor(this); } - m_first = false; + m_First = false; } QDialog::showEvent(e); @@ -126,7 +127,7 @@ void ValidationProgressDialog::onHide() void ValidationProgressDialog::onCancel() { - m_validator.cancel(); + m_Validator.cancel(); } void ValidationProgressDialog::onTimer() @@ -136,7 +137,7 @@ void ValidationProgressDialog::onTimer() void ValidationProgressDialog::updateProgress() { - const auto* current = m_validator.currentAttempt(); + const auto* current = m_Validator.currentAttempt(); if (current) { ui->progress->setRange(0, current->timeout().count()); @@ -146,7 +147,7 @@ void ValidationProgressDialog::updateProgress() ui->progress->setRange(0, 0); } - if (const auto* a = m_validator.lastAttempt()) { + if (const auto* a = m_Validator.lastAttempt()) { ui->label->setText(a->message() + ". " + tr("Trying again...")); } else if (current) { ui->label->setText(tr("Connecting to Nexus...")); @@ -156,26 +157,26 @@ void ValidationProgressDialog::updateProgress() } ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) - : m_reply(nullptr), m_result(None) + : m_Reply(nullptr), m_Result(None) { - m_timeout.setSingleShot(true); - m_timeout.setInterval(timeout); + m_Timeout.setSingleShot(true); + m_Timeout.setInterval(timeout); - QObject::connect(&m_timeout, &QTimer::timeout, [&] { + QObject::connect(&m_Timeout, &QTimer::timeout, [&] { onTimeout(); }); } void ValidationAttempt::start(NXMAccessManager& m, const NexusOAuthTokens& tokens) { - m_tokens = tokens; + m_Tokens = tokens; if (!sendRequest(m, tokens)) { return; } - m_elapsed.start(); - m_timeout.start(); + m_Elapsed.start(); + m_Timeout.start(); log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); } @@ -188,38 +189,29 @@ bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& return false; } - QString requestUrl; QNetworkRequest request; + QString requestUrl; if (!tokens.accessToken.isEmpty()) { requestUrl = NexusUserUrl + "userinfo"; - request.setUrl(requestUrl); - const auto bearer = QStringLiteral("Bearer %1").arg(tokens.accessToken); - request.setRawHeader("Authorization", bearer.toUtf8()); + m_Reply = + NexusInterface::instance().getAccessManager()->makeOAuthGetRequest(requestUrl); } else { requestUrl = NexusV1BaseUrl + "users/validate"; request.setUrl(requestUrl); request.setRawHeader("APIKEY", tokens.apiKey.toUtf8()); + m_Reply = m.get(request); } - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m.userAgent().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); - m_reply = m.get(request); - - if (!m_reply) { + if (!m_Reply) { setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); return false; } - QObject::connect(m_reply, &QNetworkReply::finished, [&] { + QObject::connect(m_Reply, &QNetworkReply::finished, [&] { onFinished(); }); - QObject::connect(m_reply, &QNetworkReply::sslErrors, [&](auto&& errors) { + QObject::connect(m_Reply, &QNetworkReply::sslErrors, [&](auto&& errors) { onSslErrors(errors); }); @@ -228,15 +220,15 @@ bool ValidationAttempt::sendRequest(NXMAccessManager& m, const NexusOAuthTokens& void ValidationAttempt::cancel() { - if (!m_reply || m_result != None) { + if (!m_Reply || m_Result != None) { // not running return; } setFailure(Cancelled, QObject::tr("Cancelled")); - if (m_reply) { - m_reply->abort(); + if (m_Reply) { + m_Reply->abort(); } cleanup(); @@ -244,39 +236,39 @@ void ValidationAttempt::cancel() bool ValidationAttempt::done() const { - return (m_result != None); + return (m_Result != None); } ValidationAttempt::Result ValidationAttempt::result() const { - return m_result; + return m_Result; } const QString& ValidationAttempt::message() const { - return m_message; + return m_Message; } std::chrono::seconds ValidationAttempt::timeout() const { return std::chrono::duration_cast( - m_timeout.intervalAsDuration()); + m_Timeout.intervalAsDuration()); } QElapsedTimer ValidationAttempt::elapsed() const { - return m_elapsed; + return m_Elapsed; } void ValidationAttempt::onFinished() { - if (m_result == Cancelled) { + if (m_Result == Cancelled) { return; } log::debug("nexus: request has finished"); - if (!m_reply) { + if (!m_Reply) { // shouldn't happen log::error("nexus: reply is null"); setFailure(HardError, QObject::tr("Internal error")); @@ -284,25 +276,25 @@ void ValidationAttempt::onFinished() } const auto code = - m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + m_Reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (code == 0) { // request wasn't even sent log::error("nexus: code is 0"); - setFailure(SoftError, m_reply->errorString()); + setFailure(SoftError, m_Reply->errorString()); return; } - const auto doc = QJsonDocument::fromJson(m_reply->readAll()); - const auto headers = m_reply->rawHeaderPairs(); - const auto httpError = m_reply->errorString(); + const auto doc = QJsonDocument::fromJson(m_Reply->readAll()); + const auto headers = m_Reply->rawHeaderPairs(); + const auto httpError = m_Reply->errorString(); const QJsonObject data = doc.object(); if (code != 200) { // http request failed - QString s = m_reply->errorString(); + QString s = m_Reply->errorString(); const auto nexusMessage = data.value("message").toString(); if (!nexusMessage.isEmpty()) { @@ -328,7 +320,7 @@ void ValidationAttempt::onFinished() return; } - if (!m_tokens.accessToken.isEmpty()) { + if (!m_Tokens.accessToken.isEmpty()) { if (!data.contains("sub")) { setFailure(HardError, QObject::tr("Bad response")); return; @@ -347,21 +339,21 @@ void ValidationAttempt::onFinished() } } - if (m_tokens.accessToken.isEmpty()) { + if (m_Tokens.accessToken.isEmpty()) { setFailure(HardError, QObject::tr("Access token is empty")); return; } const auto user = APIUserAccount() - .accessToken(m_tokens.accessToken) + .accessToken(m_Tokens.accessToken) .id(QString("%1").arg(id)) .name(name) .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) .limits(NexusInterface::defaultAPILimits()); setSuccess(user); - } else if (!m_tokens.apiKey.isEmpty()) { + } else if (!m_Tokens.apiKey.isEmpty()) { if (!data.contains("user_id")) { setFailure(HardError, QObject::tr("Bad response")); return; @@ -374,7 +366,7 @@ void ValidationAttempt::onFinished() const auto user = APIUserAccount() - .apiKey(m_tokens.apiKey) + .apiKey(m_Tokens.apiKey) .id(QString("%1").arg(id)) .name(name) .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) @@ -409,8 +401,8 @@ void ValidationAttempt::setFailure(Result r, const QString& error) cleanup(); - m_result = r; - m_message = error; + m_Result = r; + m_Message = error; if (failure) { failure(); @@ -422,8 +414,8 @@ void ValidationAttempt::setSuccess(const APIUserAccount& user) log::debug("nexus connection successful"); cleanup(); - m_result = Success; - m_message = ""; + m_Result = Success; + m_Message = ""; if (success) { success(user); @@ -432,17 +424,17 @@ void ValidationAttempt::setSuccess(const APIUserAccount& user) void ValidationAttempt::cleanup() { - m_timeout.stop(); + m_Timeout.stop(); - if (m_reply) { - m_reply->disconnect(); - m_reply->deleteLater(); - m_reply = nullptr; + if (m_Reply) { + m_Reply->disconnect(); + m_Reply->deleteLater(); + m_Reply = nullptr; } } NexusKeyValidator::NexusKeyValidator(Settings* s, NXMAccessManager& am) - : m_settings(s), m_manager(am) + : m_Settings(s), m_Manager(am) {} NexusKeyValidator::~NexusKeyValidator() @@ -452,8 +444,8 @@ NexusKeyValidator::~NexusKeyValidator() std::vector NexusKeyValidator::getTimeouts() const { - if (m_settings) { - return m_settings->nexus().validationTimeouts(); + if (m_Settings) { + return m_Settings->nexus().validationTimeouts(); } else { return {10s, 15s, 20s}; } @@ -466,7 +458,7 @@ void NexusKeyValidator::start(const NexusOAuthTokens& tokens, Behaviour b) return; } - m_tokens = tokens; + m_Tokens = tokens; const auto timeouts = getTimeouts(); @@ -488,10 +480,10 @@ void NexusKeyValidator::start(const NexusOAuthTokens& tokens, Behaviour b) void NexusKeyValidator::createAttempts( const std::vector& timeouts) { - m_attempts.clear(); + m_Attempts.clear(); for (auto&& t : timeouts) { - m_attempts.push_back(std::make_unique(t)); + m_Attempts.push_back(std::make_unique(t)); } } @@ -499,14 +491,14 @@ void NexusKeyValidator::cancel() { log::debug("nexus: connection cancelled"); - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { a->cancel(); } } bool NexusKeyValidator::isActive() const { - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (!a->done()) { return true; } @@ -519,7 +511,7 @@ const ValidationAttempt* NexusKeyValidator::lastAttempt() const { const ValidationAttempt* last = nullptr; - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (a->done()) { last = a.get(); } else { @@ -532,7 +524,7 @@ const ValidationAttempt* NexusKeyValidator::lastAttempt() const const ValidationAttempt* NexusKeyValidator::currentAttempt() const { - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (!a->done()) { return a.get(); } @@ -543,12 +535,12 @@ const ValidationAttempt* NexusKeyValidator::currentAttempt() const bool NexusKeyValidator::nextTry() { - if (!m_tokens) { + if (!m_Tokens) { log::error("nexus: validator invoked without tokens"); return false; } - for (auto&& a : m_attempts) { + for (auto&& a : m_Attempts) { if (!a->done()) { a->success = [&](auto&& user) { onAttemptSuccess(*a, user); @@ -557,7 +549,7 @@ bool NexusKeyValidator::nextTry() onAttemptFailure(*a); }; - a->start(m_manager, *m_tokens); + a->start(m_Manager, *m_Tokens); return true; } } @@ -615,13 +607,55 @@ void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, const QString& moVersion) : QNetworkAccessManager(parent), m_Settings(s), m_MOVersion(moVersion), - m_validator(s, *this), m_validationState(NotChecked) + m_Validator(s, *this), m_ValidationState(NotChecked) { - m_validator.finished = [&](auto&& r, auto&& m, auto&& u) { + m_NexusOAuth.reset(new QOAuth2AuthorizationCodeFlow); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, + [&](QAbstractOAuth::Error error) { + handleOAuthError(QObject::tr("Authorization failed (%1)").arg(int(error))); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::granted, this, [&]() { + notifyTokens(); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::tokenChanged, this, [&]() { + tokensReceived = [&](const NexusOAuthTokens& tokens) { + saveRefreshedTokens(tokens); + }; + stateChanged = nullptr; + notifyTokens(); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, + [&](const QUrl& url) { + shell::Open(url); + setOAuthState(OAuthState::WaitingForBrowser); + }); + + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::statusChanged, this, + [&](QAbstractOAuth::Status status) { + switch (status) { + case QAbstractOAuth::Status::RefreshingToken: + setOAuthState(OAuthState::Refreshing); + break; + case QAbstractOAuth::Status::TemporaryCredentialsReceived: + setOAuthState(OAuthState::Authorizing); + break; + case QAbstractOAuth::Status::Granted: + setOAuthState(OAuthState::Finished); + break; + default: + break; + } + }); + + m_Validator.finished = [&](auto&& r, auto&& m, auto&& u) { onValidatorFinished(r, m, u); }; - m_validator.attemptFinished = [&](auto&& a) { + m_Validator.attemptFinished = [&](auto&& a) { onValidatorAttemptFinished(a); }; @@ -639,7 +673,7 @@ void NXMAccessManager::setTopLevelWidget(QWidget* w) } } else { m_ProgressDialog.reset(); - m_validator.cancel(); + m_Validator.cancel(); } } @@ -685,112 +719,124 @@ void NXMAccessManager::clearCookies() void NXMAccessManager::setTokens(const NexusOAuthTokens& tokens) { - m_tokens = tokens; + m_Tokens = tokens; } std::optional NXMAccessManager::tokens() const { - return m_tokens; + return m_Tokens; } -bool NXMAccessManager::ensureFreshToken() +void NXMAccessManager::handleOAuthError(const QString& message) { - if (!m_tokens) { - log::warn("nexus: no OAuth tokens available"); - return false; + if (m_NexusOAuthReplyHandler) { + m_NexusOAuthReplyHandler->close(); + } + m_NexusOAuthReplyHandler.reset(); + if (stateChanged) { + stateChanged(OAuthState::Error, message); } + emit authorizationEnded(); +} - if (!m_tokens->accessToken.isEmpty()) { - if (!m_tokens->isExpired()) { - return true; - } +void NXMAccessManager::notifyTokens() +{ + if (!m_NexusOAuth) { + handleOAuthError(QObject::tr("Internal error: OAuth flow is missing.")); + return; + } - const auto refreshed = refreshTokensBlocking(*m_tokens); - if (!refreshed) { - return false; - } + QVariantMap payload; - setTokens(*refreshed); - GlobalSettings::setNexusOAuthTokens(*refreshed); - return true; + auto scopeTokens = m_NexusOAuth->grantedScopeTokens(); + QStringList scopes; + for (auto token : scopeTokens) { + scopes.append(QString::fromUtf8(token.constData())); } + payload["access_token"] = m_NexusOAuth->token(); + payload["refresh_token"] = m_NexusOAuth->refreshToken(); + payload["scope"] = scopes.join(" "); + payload["expiration_at"] = m_NexusOAuth->expirationAt(); - return true; + const auto extras = m_NexusOAuth->extraTokens(); + payload.insert(extras); + + auto tokens = makeTokensFromResponse(payload); + if (!tokens.isValid()) { + handleOAuthError(QObject::tr("Invalid OAuth token payload.")); + return; + } + + tokens.scope = m_NexusOAuth->scope(); + + if (tokensReceived) { + tokensReceived(tokens); + } + emit authorizationEnded(); } -void NXMAccessManager::startValidationCheck(const NexusOAuthTokens& tokens) +void NXMAccessManager::saveRefreshedTokens(const NexusOAuthTokens tokens) { - m_validationState = NotChecked; - m_validator.start(tokens, NexusKeyValidator::Retry); - - if (m_ProgressDialog) { - // don't show the progress dialog on startup for the first attempt; the - // dialog will be shown in onValidatorAttemptFinished() if it failed - startProgress(); + NexusOAuthTokens finalTokens; + if (GlobalSettings::hasNexusOAuthTokens()) { + NexusOAuthTokens oldTokens; + GlobalSettings::nexusOAuthTokens(oldTokens); + NexusOAuthTokens newTokens(tokens); + if (tokens.apiKey.isEmpty()) { + newTokens.apiKey = oldTokens.apiKey; + } + finalTokens = newTokens; + } else { + finalTokens = tokens; + } + const bool ret = GlobalSettings::setNexusOAuthTokens(finalTokens); + if (ret) { + setTokens(finalTokens); } } -std::optional -NXMAccessManager::refreshTokensBlocking(const NexusOAuthTokens& current) +void NXMAccessManager::setOAuthState(OAuthState state, const QString& message) { - if (current.refreshToken.isEmpty()) { - log::error("nexus: refresh token missing, user interaction required"); - return std::nullopt; + if (stateChanged) { + stateChanged(state, message); } +} - QNetworkRequest request{QUrl(NexusOAuth::tokenUrl())}; - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/x-www-form-urlencoded"); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - userAgent().toUtf8()); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", MOVersion().toUtf8()); +QString NXMAccessManager::stateToString(OAuthState state, const QString& details) +{ + switch (state) { + case OAuthState::Initializing: + return QObject::tr("Connecting to Nexus..."); - QUrlQuery formData; - formData.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("refresh_token")); - formData.addQueryItem(QStringLiteral("refresh_token"), current.refreshToken); - formData.addQueryItem(QStringLiteral("client_id"), NexusOAuth::clientId()); + case OAuthState::WaitingForBrowser: + return QObject::tr("Opened Nexus in browser.") + "\n" + + QObject::tr("Switch to your browser and accept the request."); - auto reply = post(request, formData.toString(QUrl::FullyEncoded).toUtf8()); - if (!reply) { - log::error("nexus: failed to issue refresh token request"); - return std::nullopt; - } + case OAuthState::Authorizing: + return QObject::tr("Waiting for Nexus..."); - QEventLoop loop; - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - loop.exec(); + case OAuthState::Finished: + return QObject::tr("Finished."); - if (reply->error() != QNetworkReply::NoError) { - log::error("nexus: refresh token request failed - {}", reply->errorString()); - reply->deleteLater(); - return std::nullopt; - } + case OAuthState::Cancelled: + return QObject::tr("Cancelled."); - const auto payload = QJsonDocument::fromJson(reply->readAll()); - reply->deleteLater(); - if (!payload.isObject()) { - log::error("nexus: invalid refresh token payload"); - return std::nullopt; + case OAuthState::Error: + default: + return details.isEmpty() ? QObject::tr("An unknown error has occurred.") : details; } +} - auto tokens = makeTokensFromResponse(payload.object()); - if (tokens.refreshToken.isEmpty()) { - tokens.refreshToken = current.refreshToken; - } - if (tokens.scope.isEmpty()) { - tokens.scope = current.scope; - } - if (tokens.tokenType.isEmpty()) { - tokens.tokenType = current.tokenType; - } +void NXMAccessManager::startValidationCheck(const NexusOAuthTokens& tokens) +{ + m_ValidationState = NotChecked; + m_Validator.start(tokens, NexusKeyValidator::Retry); - if (!tokens.isValid()) { - return std::nullopt; + if (m_ProgressDialog) { + // don't show the progress dialog on startup for the first attempt; the + // dialog will be shown in onValidatorAttemptFinished() if it failed + startProgress(); } - - return tokens; } void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, @@ -800,14 +846,14 @@ void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, stopProgress(); if (user) { - m_validationState = Valid; + m_ValidationState = Valid; emit credentialsReceived(*user); emit validateSuccessful(true); } else { if (r == ValidationAttempt::Cancelled) { - m_validationState = NotChecked; + m_ValidationState = NotChecked; } else { - m_validationState = Invalid; + m_ValidationState = Invalid; emit validateFailed(message); } } @@ -836,11 +882,11 @@ void NXMAccessManager::onValidatorAttemptFinished(const ValidationAttempt& a) bool NXMAccessManager::validated() const { - if (m_validationState == Valid) { + if (m_ValidationState == Valid) { return true; } - if (m_validator.isActive()) { + if (m_Validator.isActive()) { const_cast(this)->startProgress(); } @@ -849,41 +895,148 @@ bool NXMAccessManager::validated() const void NXMAccessManager::refuseValidation() { - m_validationState = Invalid; + m_ValidationState = Invalid; } bool NXMAccessManager::validateAttempted() const { - return (m_validationState != NotChecked); + return (m_ValidationState != NotChecked); } bool NXMAccessManager::validateWaiting() const { - return m_validator.isActive(); + return m_Validator.isActive(); +} + +void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) +{ + const auto clientId = NexusOAuth::clientId(); + if (clientId.isEmpty()) { + handleOAuthError(QObject::tr("No OAuth client id configured.")); + return; + } + m_NexusOAuth->setAuthorizationUrl(QUrl(NexusOAuth::authorizeUrl())); + m_NexusOAuth->setTokenUrl(QUrl(NexusOAuth::tokenUrl())); + m_NexusOAuth->setClientIdentifier(clientId); + m_NexusOAuth->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); + m_NexusOAuth->setAutoRefresh(true); + m_NexusOAuth->setRefreshLeadTime(std::chrono::seconds(300)); + + // m_NexusOAuth->setModifyParametersFunction( + // [this](QAbstractOAuth::Stage stage, QMultiMap* parameters) + // { + // injectPkceChallenge(stage, parameters); + // }); + QSet scope = {"openid", "profile", "email"}; + m_NexusOAuth->setRequestedScopeTokens(scope); + m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( + QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); + m_NexusOAuthReplyHandler->setCallbackPath(QUrl(NexusOAuth::redirectUri()).path()); + m_NexusOAuthReplyHandler->setCallbackText( + QObject::tr("

Mod Organizer

Authorization complete. You " + "may close this " + "window.

")); + if (!m_NexusOAuthReplyHandler->isListening() && + !m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, + NexusOAuth::redirectPort())) { + handleOAuthError(QObject::tr("Failed to bind to localhost on port %1.") + .arg(NexusOAuth::redirectPort())); + return; + } + m_NexusOAuth->setReplyHandler(m_NexusOAuthReplyHandler.get()); + if (!tokens.accessToken.isEmpty()) { + m_NexusOAuth->setToken(tokens.accessToken); + m_NexusOAuth->setRefreshToken(tokens.refreshToken); + scope.clear(); + for (const QString scopeItem : tokens.scope.split(" ")) { + scope.insert(scopeItem.toUtf8()); + } + m_NexusOAuth->setRequestedScopeTokens(scope); + + setOAuthState(OAuthState::Refreshing); + m_NexusOAuth->refreshTokens(); + } else { + + setOAuthState(OAuthState::Initializing); + m_NexusOAuth->grant(); + } +} + +void NXMAccessManager::cancelAuth() +{ + if (m_NexusOAuthReplyHandler) { + m_NexusOAuthReplyHandler->close(); + } + + m_NexusOAuth.reset(); + m_NexusOAuthReplyHandler.reset(); + setOAuthState(OAuthState::Cancelled); +} + +QNetworkReply* NXMAccessManager::makeOAuthGetRequest(const QUrl url) +{ + if (!m_NexusOAuth->token().isEmpty()) { + QNetworkRequest request(url); + m_NexusOAuth->prepareRequest(&request, "GET"); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + userAgent().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", MOVersion().toUtf8()); + return m_NexusOAuth->networkAccessManager()->get(request); + } + return nullptr; +} + +QNetworkReply* NXMAccessManager::makeOAuthPostRequest(const QUrl url, + const QByteArray payload = {}) +{ + if (!m_NexusOAuth->token().isEmpty()) { + QNetworkRequest request(url); + m_NexusOAuth->prepareRequest(&request, "POST", payload); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + userAgent().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", MOVersion().toUtf8()); + return m_NexusOAuth->networkAccessManager()->post(request, payload); + } + return nullptr; } void NXMAccessManager::apiCheck(const NexusOAuthTokens& tokens, bool force) { - if (m_validator.isActive()) { + if (m_Validator.isActive()) { return; } setTokens(tokens); if (m_Settings && m_Settings->network().offlineMode()) { - m_validationState = NotChecked; + m_ValidationState = NotChecked; return; } if (force) { - m_validationState = NotChecked; + m_ValidationState = NotChecked; } - if (m_validationState == Valid) { + if (m_ValidationState == Valid) { emit validateSuccessful(false); return; } + if (m_NexusOAuth->token().isEmpty() && !tokens.accessToken.isEmpty()) { + tokensReceived = [&](const NexusOAuthTokens& tokens) { + saveRefreshedTokens(tokens); + }; + stateChanged = nullptr; + connectOrRefresh(tokens); + } startValidationCheck(tokens); } @@ -914,15 +1067,28 @@ QString NXMAccessManager::userAgent(const QString& subModule) const void NXMAccessManager::clearTokens() { - m_validator.cancel(); - m_tokens.reset(); + m_Validator.cancel(); + // TODO: Verify revocation process + // if (m_Tokens && !m_Tokens->accessToken.isEmpty()) { + // QNetworkRequest request(NexusOAuth::tokenUrl()); + // QUrlQuery params; + // params.addQueryItem("token", m_Tokens->refreshToken); + // params.addQueryItem("token_type_hint", "refresh_token"); + // m_NexusOAuth->prepareRequest(&request, "POST", + // params.toString(QUrl::FullyEncoded).toUtf8()); + // m_NexusOAuth->networkAccessManager()->post( + // request, params.toString(QUrl::FullyEncoded).toUtf8()); + //} + m_Tokens.reset(); + m_NexusOAuthReplyHandler.reset(); + m_NexusOAuth.reset(); emit credentialsReceived(APIUserAccount()); } void NXMAccessManager::startProgress() { if (!m_ProgressDialog) { - m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_validator)); + m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_Validator)); } m_ProgressDialog->start(); diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index 62f0a2065..d89752ff4 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -29,6 +29,8 @@ along with Mod Organizer. If not, see . #include #include #include +#include +#include #include #include @@ -68,12 +70,12 @@ class ValidationAttempt QElapsedTimer elapsed() const; private: - QNetworkReply* m_reply; - Result m_result; - QString m_message; - QTimer m_timeout; - QElapsedTimer m_elapsed; - NexusOAuthTokens m_tokens; + QNetworkReply* m_Reply; + Result m_Result; + QString m_Message; + QTimer m_Timeout; + QElapsedTimer m_Elapsed; + NexusOAuthTokens m_Tokens; bool sendRequest(NXMAccessManager& m, const NexusOAuthTokens& tokens); @@ -113,10 +115,10 @@ class NexusKeyValidator const ValidationAttempt* currentAttempt() const; private: - Settings* m_settings; - NXMAccessManager& m_manager; - std::optional m_tokens; - std::vector> m_attempts; + Settings* m_Settings; + NXMAccessManager& m_Manager; + std::optional m_Tokens; + std::vector> m_Attempts; void createAttempts(const std::vector& timeouts); std::vector getTimeouts() const; @@ -147,10 +149,10 @@ class ValidationProgressDialog : public QDialog private: std::unique_ptr ui; - Settings* m_settings; - NexusKeyValidator& m_validator; - QTimer* m_updateTimer; - bool m_first; + Settings* m_Settings; + NexusKeyValidator& m_Validator; + QTimer* m_UpdateTimer; + bool m_First; void onHide(); void onCancel(); @@ -165,6 +167,17 @@ class NXMAccessManager : public QNetworkAccessManager { Q_OBJECT public: + enum class OAuthState + { + Initializing, + WaitingForBrowser, + Authorizing, + Refreshing, + Finished, + Cancelled, + Error + }; + NXMAccessManager(QObject* parent, Settings* s, const QString& moVersion); void setTopLevelWidget(QWidget* w); @@ -174,10 +187,20 @@ class NXMAccessManager : public QNetworkAccessManager bool validateAttempted() const; bool validateWaiting() const; + void connectOrRefresh(const NexusOAuthTokens tokens); + void cancelAuth(); + + QNetworkReply* makeOAuthGetRequest(const QUrl url); + QNetworkReply* makeOAuthPostRequest(const QUrl url, const QByteArray payload); + + static QString stateToString(OAuthState state, const QString& details = {}); + + std::function tokensReceived; + std::function stateChanged; + void apiCheck(const NexusOAuthTokens& tokens, bool force = false); void setTokens(const NexusOAuthTokens& tokens); std::optional tokens() const; - bool ensureFreshToken(); void showCookies() const; @@ -208,6 +231,7 @@ class NXMAccessManager : public QNetworkAccessManager void validateSuccessful(bool necessary); void validateFailed(const QString& message); void credentialsReceived(const APIUserAccount& user); + void authorizationEnded(); protected: virtual QNetworkReply* createRequest(QNetworkAccessManager::Operation operation, @@ -226,13 +250,20 @@ class NXMAccessManager : public QNetworkAccessManager Settings* m_Settings; mutable std::unique_ptr m_ProgressDialog; QString m_MOVersion; - NexusKeyValidator m_validator; - States m_validationState; - std::optional m_tokens; + NexusKeyValidator m_Validator; + States m_ValidationState; + std::optional m_Tokens; + std::unique_ptr m_NexusOAuth; + std::unique_ptr m_NexusOAuthReplyHandler; + + void handleOAuthError(const QString& message); + + void setOAuthState(OAuthState state, const QString& message = {}); + void notifyTokens(); + + void saveRefreshedTokens(const NexusOAuthTokens tokens); void startValidationCheck(const NexusOAuthTokens& tokens); - std::optional - refreshTokensBlocking(const NexusOAuthTokens& current); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional); diff --git a/src/settingsdialognexus.cpp b/src/settingsdialognexus.cpp index 9b4cf146a..9974c09d5 100644 --- a/src/settingsdialognexus.cpp +++ b/src/settingsdialognexus.cpp @@ -210,10 +210,11 @@ void NexusConnectionUI::onTokensReceived(const NexusOAuthTokens& tokens) validateTokens(tokens); } -void NexusConnectionUI::onOAuthStateChanged(NexusOAuthLogin::State s, const QString& e) +void NexusConnectionUI::onOAuthStateChanged(NXMAccessManager::OAuthState s, + const QString& e) { - if (s != NexusOAuthLogin::State::Finished) { - const auto log = NexusOAuthLogin::stateToString(s, e); + if (s != NXMAccessManager::OAuthState::Finished) { + const auto log = NXMAccessManager::stateToString(s, e); for (auto&& line : log.split("\n")) { addLog(line); diff --git a/src/settingsdialognexus.h b/src/settingsdialognexus.h index df3bd3cec..96980b689 100644 --- a/src/settingsdialognexus.h +++ b/src/settingsdialognexus.h @@ -51,7 +51,7 @@ class NexusConnectionUI : public QObject bool clearTokens(); void onTokensReceived(const NexusOAuthTokens& tokens); - void onOAuthStateChanged(NexusOAuthLogin::State s, const QString& message); + void onOAuthStateChanged(NXMAccessManager::OAuthState s, const QString& message); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, std::optional user); From 6b8239f8b4cd08f3b913f806f921423f5487ff4f Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Mon, 20 Apr 2026 21:38:23 -0500 Subject: [PATCH 07/11] Various fixes - Consolidate API header function - Extend OAuth request functions - Remove unneeded function - Wait for auth to finish before doing validate - Fallback refresh as autorefresh is inconsistent - Better queueing of auth / validate --- src/nexusinterface.cpp | 22 ++-------- src/nexusoauthtokens.h | 17 -------- src/nxmaccessmanager.cpp | 89 +++++++++++++++++++++++++++++----------- src/nxmaccessmanager.h | 6 +++ 4 files changed, 74 insertions(+), 60 deletions(-) diff --git a/src/nexusinterface.cpp b/src/nexusinterface.cpp index 1320fdfd0..113b5dde6 100644 --- a/src/nexusinterface.cpp +++ b/src/nexusinterface.cpp @@ -985,11 +985,6 @@ void NexusInterface::nextRequest() } else { url = info.m_URL; } - // if (!m_AccessManager->ensureFreshToken()) { - // log::error("nexus: unable to refresh OAuth token, request aborted"); - // info.m_Reply = nullptr; - // return; - // } const auto currentTokens = m_AccessManager->tokens(); if (!currentTokens || @@ -1005,7 +1000,8 @@ void NexusInterface::nextRequest() if (!requestIsDelete) { info.m_Reply = m_AccessManager->makeOAuthGetRequest(url); } else { - info.m_Reply = m_AccessManager->deleteResource(request); + m_AccessManager->addAPIHeaders(request); + info.m_Reply = m_AccessManager->makeOAuthDeleteRequest(request); } } else if (!requestIsDelete) { info.m_Reply = m_AccessManager->makeOAuthPostRequest(url, postData.toJson()); @@ -1013,21 +1009,11 @@ void NexusInterface::nextRequest() // Qt doesn't support DELETE with a payload as that's technically against the HTTP // standard... info.m_Reply = - m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); + m_AccessManager->makeOAuthCustomRequest(request, "DELETE", postData.toJson()); } } else { - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, - QNetworkRequest::AlwaysNetwork); request.setRawHeader("APIKEY", currentTokens->apiKey.toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - m_AccessManager->userAgent(info.m_SubModule)); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", - QApplication::applicationVersion().toUtf8()); + m_AccessManager->addAPIHeaders(request); if (postData.object().isEmpty()) { if (!requestIsDelete) { diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index 21c1d5008..c8d1fa6ba 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -88,23 +88,6 @@ struct NexusOAuthTokens } }; -inline NexusOAuthTokens makeTokensFromResponse(const QJsonObject& json) -{ - NexusOAuthTokens tokens; - tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); - tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); - tokens.scope = json.value(QStringLiteral("scope")).toString(); - tokens.tokenType = json.value(QStringLiteral("token_type")).toString(); - - const auto expiresIn = json.value(QStringLiteral("expires_in")).toInt(); - if (expiresIn > 0) { - tokens.expiresAt = - QDateTime::currentDateTimeUtc().addSecs(static_cast(expiresIn)); - } - - return tokens; -} - inline NexusOAuthTokens makeTokensFromResponse(const QVariantMap& data) { NexusOAuthTokens tokens; diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 60c97a511..36d5f0b7a 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -727,12 +727,28 @@ std::optional NXMAccessManager::tokens() const return m_Tokens; } -void NXMAccessManager::handleOAuthError(const QString& message) +void NXMAccessManager::ensureFreshToken() { - if (m_NexusOAuthReplyHandler) { - m_NexusOAuthReplyHandler->close(); + if (!m_Tokens || (m_Tokens->apiKey.isEmpty() && m_Tokens->accessToken.isEmpty())) { + log::warn("nexus: no OAuth tokens available"); + return; } - m_NexusOAuthReplyHandler.reset(); + + if (!m_Tokens->accessToken.isEmpty()) { + if (!m_Tokens->isExpired()) { + return; + } + + tokensReceived = [&](const NexusOAuthTokens& tokens) { + saveRefreshedTokens(tokens); + }; + stateChanged = nullptr; + connectOrRefresh(*m_Tokens); + } +} + +void NXMAccessManager::handleOAuthError(const QString& message) +{ if (stateChanged) { stateChanged(OAuthState::Error, message); } @@ -772,6 +788,8 @@ void NXMAccessManager::notifyTokens() if (tokensReceived) { tokensReceived(tokens); } + + startValidationCheck(tokens); emit authorizationEnded(); } @@ -921,12 +939,6 @@ void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) m_NexusOAuth->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); m_NexusOAuth->setAutoRefresh(true); m_NexusOAuth->setRefreshLeadTime(std::chrono::seconds(300)); - - // m_NexusOAuth->setModifyParametersFunction( - // [this](QAbstractOAuth::Stage stage, QMultiMap* parameters) - // { - // injectPkceChallenge(stage, parameters); - // }); QSet scope = {"openid", "profile", "email"}; m_NexusOAuth->setRequestedScopeTokens(scope); m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( @@ -973,18 +985,27 @@ void NXMAccessManager::cancelAuth() setOAuthState(OAuthState::Cancelled); } +void NXMAccessManager::addAPIHeaders(QNetworkRequest& request) +{ + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + userAgent().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", MOVersion().toUtf8()); +} + QNetworkReply* NXMAccessManager::makeOAuthGetRequest(const QUrl url) { if (!m_NexusOAuth->token().isEmpty()) { + ensureFreshToken(); QNetworkRequest request(url); m_NexusOAuth->prepareRequest(&request, "GET"); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - userAgent().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", MOVersion().toUtf8()); + addAPIHeaders(request); return m_NexusOAuth->networkAccessManager()->get(request); } return nullptr; @@ -994,20 +1015,37 @@ QNetworkReply* NXMAccessManager::makeOAuthPostRequest(const QUrl url, const QByteArray payload = {}) { if (!m_NexusOAuth->token().isEmpty()) { + ensureFreshToken(); QNetworkRequest request(url); m_NexusOAuth->prepareRequest(&request, "POST", payload); - request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, - userAgent().toUtf8()); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - request.setRawHeader("Protocol-Version", "1.0.0"); - request.setRawHeader("Application-Name", "MO2"); - request.setRawHeader("Application-Version", MOVersion().toUtf8()); + addAPIHeaders(request); return m_NexusOAuth->networkAccessManager()->post(request, payload); } return nullptr; } +QNetworkReply* NXMAccessManager::makeOAuthDeleteRequest(QNetworkRequest request) +{ + if (!m_NexusOAuth->token().isEmpty()) { + ensureFreshToken(); + m_NexusOAuth->prepareRequest(&request, "DELETE"); + addAPIHeaders(request); + return m_NexusOAuth->networkAccessManager()->deleteResource(request); + } + return nullptr; +} + +QNetworkReply* NXMAccessManager::makeOAuthCustomRequest(QNetworkRequest request, const QByteArray& verb, const QByteArray& data) +{ + if (!m_NexusOAuth->token().isEmpty()) { + ensureFreshToken(); + m_NexusOAuth->prepareRequest(&request, verb, data); + addAPIHeaders(request); + return m_NexusOAuth->networkAccessManager()->sendCustomRequest(request, verb, data); + } + return nullptr; +} + void NXMAccessManager::apiCheck(const NexusOAuthTokens& tokens, bool force) { if (m_Validator.isActive()) { @@ -1036,8 +1074,9 @@ void NXMAccessManager::apiCheck(const NexusOAuthTokens& tokens, bool force) }; stateChanged = nullptr; connectOrRefresh(tokens); + } else if (!tokens.apiKey.isEmpty()) { + startValidationCheck(tokens); } - startValidationCheck(tokens); } const QString& NXMAccessManager::MOVersion() const diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index d89752ff4..a2c480cfb 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -192,12 +192,17 @@ class NXMAccessManager : public QNetworkAccessManager QNetworkReply* makeOAuthGetRequest(const QUrl url); QNetworkReply* makeOAuthPostRequest(const QUrl url, const QByteArray payload); + QNetworkReply* makeOAuthDeleteRequest(QNetworkRequest request); + QNetworkReply* makeOAuthCustomRequest(QNetworkRequest request, const QByteArray& verb, + const QByteArray& data); static QString stateToString(OAuthState state, const QString& details = {}); std::function tokensReceived; std::function stateChanged; + void addAPIHeaders(QNetworkRequest& request); + void apiCheck(const NexusOAuthTokens& tokens, bool force = false); void setTokens(const NexusOAuthTokens& tokens); std::optional tokens() const; @@ -256,6 +261,7 @@ class NXMAccessManager : public QNetworkAccessManager std::unique_ptr m_NexusOAuth; std::unique_ptr m_NexusOAuthReplyHandler; + void ensureFreshToken(); void handleOAuthError(const QString& message); void setOAuthState(OAuthState state, const QString& message = {}); From 33d53780ae62544b2e297e95aeb91911e849ebec Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Fri, 24 Apr 2026 10:31:05 -0500 Subject: [PATCH 08/11] Restore old API Key store - Functions as fallback / backwards compat - Fix a couple minor oversights --- src/createinstancedialogpages.cpp | 2 +- src/moapplication.cpp | 3 +- src/modlistviewactions.cpp | 6 ++-- src/nexusoauthtokens.h | 2 -- src/nxmaccessmanager.cpp | 16 +++++---- src/nxmaccessmanager.h | 2 +- src/settings.cpp | 56 ++++++++++++++++++------------- src/settings.h | 18 ++++++++++ src/settingsdialognexus.cpp | 30 ++++++++++------- src/settingsdialognexus.h | 4 +-- 10 files changed, 88 insertions(+), 51 deletions(-) diff --git a/src/createinstancedialogpages.cpp b/src/createinstancedialogpages.cpp index 778c73b7b..ac4103ea5 100644 --- a/src/createinstancedialogpages.cpp +++ b/src/createinstancedialogpages.cpp @@ -1204,7 +1204,7 @@ NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg), m_skip(false) // just check it once, or connecting and then going back and forth would skip // the page, which would be unexpected - m_skip = GlobalSettings::hasNexusOAuthTokens(); + m_skip = GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey(); } NexusPage::~NexusPage() = default; diff --git a/src/moapplication.cpp b/src/moapplication.cpp index 7b0f7d425..a8900acb8 100644 --- a/src/moapplication.cpp +++ b/src/moapplication.cpp @@ -320,7 +320,8 @@ int MOApplication::run(MOMultiProcess& multiProcess) // start an api check NexusOAuthTokens tokens; - if (GlobalSettings::nexusOAuthTokens(tokens)) { + if (GlobalSettings::nexusOAuthTokens(tokens) || + GlobalSettings::nexusApiKey(tokens.apiKey)) { m_nexus->getAccessManager()->apiCheck(tokens); } diff --git a/src/modlistviewactions.cpp b/src/modlistviewactions.cpp index afd3b7fca..df2432968 100644 --- a/src/modlistviewactions.cpp +++ b/src/modlistviewactions.cpp @@ -231,7 +231,8 @@ void ModListViewActions::checkModsForUpdates() const NexusInterface::instance().requestTrackingInfo(m_receiver, QVariant(), QString()); } else { NexusOAuthTokens tokens; - if (GlobalSettings::nexusOAuthTokens(tokens)) { + if (GlobalSettings::nexusOAuthTokens(tokens) || + GlobalSettings::nexusApiKey(tokens.apiKey)) { m_core.doAfterLogin([=]() { checkModsForUpdates(); }); @@ -311,7 +312,8 @@ void ModListViewActions::checkModsForUpdates( ModInfo::manualUpdateCheck(m_receiver, IDs); } else { NexusOAuthTokens tokens; - if (GlobalSettings::nexusOAuthTokens(tokens)) { + if (GlobalSettings::nexusOAuthTokens(tokens) || + GlobalSettings::nexusApiKey(tokens.apiKey)) { m_core.doAfterLogin([=]() { checkModsForUpdates(IDs); }); diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index c8d1fa6ba..b3bc5d53f 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -52,7 +52,6 @@ struct NexusOAuthTokens QJsonObject toJson() const { QJsonObject json; - json.insert(QStringLiteral("api_key"), apiKey); json.insert(QStringLiteral("access_token"), accessToken); json.insert(QStringLiteral("refresh_token"), refreshToken); json.insert(QStringLiteral("scope"), scope); @@ -64,7 +63,6 @@ struct NexusOAuthTokens static std::optional fromJson(const QJsonObject& json) { NexusOAuthTokens tokens; - tokens.apiKey = json.value(QStringLiteral("api_key")).toString(); tokens.accessToken = json.value(QStringLiteral("access_token")).toString(); tokens.refreshToken = json.value(QStringLiteral("refresh_token")).toString(); tokens.scope = json.value(QStringLiteral("scope")).toString(); diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 36d5f0b7a..0ed63394b 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -783,7 +783,7 @@ void NXMAccessManager::notifyTokens() return; } - tokens.scope = m_NexusOAuth->scope(); + tokens.scope = scopes.join(" "); if (tokensReceived) { tokensReceived(tokens); @@ -796,9 +796,10 @@ void NXMAccessManager::notifyTokens() void NXMAccessManager::saveRefreshedTokens(const NexusOAuthTokens tokens) { NexusOAuthTokens finalTokens; - if (GlobalSettings::hasNexusOAuthTokens()) { + if (GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey()) { NexusOAuthTokens oldTokens; GlobalSettings::nexusOAuthTokens(oldTokens); + GlobalSettings::nexusApiKey(oldTokens.apiKey); NexusOAuthTokens newTokens(tokens); if (tokens.apiKey.isEmpty()) { newTokens.apiKey = oldTokens.apiKey; @@ -807,8 +808,9 @@ void NXMAccessManager::saveRefreshedTokens(const NexusOAuthTokens tokens) } else { finalTokens = tokens; } - const bool ret = GlobalSettings::setNexusOAuthTokens(finalTokens); - if (ret) { + const bool ret = GlobalSettings::setNexusOAuthTokens(finalTokens); + const bool ret2 = GlobalSettings::setNexusApiKey(finalTokens.apiKey); + if (ret && ret2) { setTokens(finalTokens); } } @@ -1035,7 +1037,9 @@ QNetworkReply* NXMAccessManager::makeOAuthDeleteRequest(QNetworkRequest request) return nullptr; } -QNetworkReply* NXMAccessManager::makeOAuthCustomRequest(QNetworkRequest request, const QByteArray& verb, const QByteArray& data) +QNetworkReply* NXMAccessManager::makeOAuthCustomRequest(QNetworkRequest request, + const QByteArray& verb, + const QByteArray& data) { if (!m_NexusOAuth->token().isEmpty()) { ensureFreshToken(); @@ -1104,7 +1108,7 @@ QString NXMAccessManager::userAgent(const QString& subModule) const .arg(m_MOVersion, comments.join("; "), qVersion()); } -void NXMAccessManager::clearTokens() +void NXMAccessManager::clearCredentials() { m_Validator.cancel(); // TODO: Verify revocation process diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index a2c480cfb..75a2fa758 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -214,7 +214,7 @@ class NXMAccessManager : public QNetworkAccessManager QString userAgent(const QString& subModule = QString()) const; const QString& MOVersion() const; - void clearTokens(); + void clearCredentials(); void refuseValidation(); diff --git a/src/settings.cpp b/src/settings.cpp index add8eef23..144ad0928 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -2520,35 +2520,45 @@ std::optional parseStoredTokens(const QString& raw) } } // namespace -bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) +bool GlobalSettings::nexusApiKey(QString& apiKey) +{ + QString tempKey = getWindowsCredential(NexusLegacyCredentialKey); + if (tempKey.isEmpty()) + return false; + + apiKey = tempKey; + return true; +} + +bool GlobalSettings::setNexusApiKey(const QString& apiKey) { - // If legacy credential key exists and is not set in the new credentials, - // insert it into the current tokens. In all cases, clear the old credential - // store once parsed. - const auto legacyRaw = getWindowsCredential(NexusLegacyCredentialKey); - if (!legacyRaw.isEmpty()) { - tokens.apiKey = legacyRaw; - setWindowsCredential(NexusLegacyCredentialKey, ""); + if (!setWindowsCredential(NexusLegacyCredentialKey, apiKey)) { + const auto e = GetLastError(); + log::error("Storing API key failed: {}", formatSystemMessage(e)); + return false; } + + return true; +} + +bool GlobalSettings::clearNexusApiKey() +{ + return setNexusApiKey(""); +} + +bool GlobalSettings::hasNexusApiKey() +{ + return !getWindowsCredential(NexusLegacyCredentialKey).isEmpty(); +} + +bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens) +{ const auto raw = getWindowsCredential(NexusOAuthCredentialKey); const auto parsed = parseStoredTokens(raw); - if (!parsed && legacyRaw.isEmpty()) { + if (!parsed) { return false; - } else if (parsed && legacyRaw.isEmpty()) { - tokens = *parsed; - } else if (parsed) { - if (!parsed->apiKey.isEmpty()) { - tokens = *parsed; - } else { - tokens.accessToken = parsed->accessToken; - tokens.refreshToken = parsed->refreshToken; - tokens.expiresAt = parsed->expiresAt; - tokens.tokenType = parsed->tokenType; - tokens.scope = parsed->scope; - setNexusOAuthTokens(tokens); - } } else { - setNexusOAuthTokens(tokens); + tokens = *parsed; } return true; } diff --git a/src/settings.h b/src/settings.h index d0cf10131..112c6599c 100644 --- a/src/settings.h +++ b/src/settings.h @@ -944,6 +944,24 @@ class GlobalSettings static bool hideAssignCategoriesQuestion(); static void setHideAssignCategoriesQuestion(bool b); + // if the key exists from the credentials store, puts it in `apiKey` and + // returns true; otherwise, returns false and leaves `apiKey` untouched + // + static bool nexusApiKey(QString& apiKey); + + // sets the api key in the credentials store, removes it if empty; returns + // false on errors + // + static bool setNexusApiKey(const QString& apiKey); + + // removes the api key from the credentials store; returns false on errors + // + static bool clearNexusApiKey(); + + // returns whether an API key is currently stored + // + static bool hasNexusApiKey(); + // Retrieves the stored OAuth tokens. Returns false if the credential doesn't exist // or can't be parsed. // diff --git a/src/settingsdialognexus.cpp b/src/settingsdialognexus.cpp index 9974c09d5..f6835797d 100644 --- a/src/settingsdialognexus.cpp +++ b/src/settingsdialognexus.cpp @@ -149,7 +149,7 @@ void NexusConnectionUI::manual() const auto key = d.key(); if (key.isEmpty()) { - clearTokens(); + clearCredentials(); return; } @@ -161,17 +161,17 @@ void NexusConnectionUI::manual() tokens->apiKey = key; NexusInterface::instance().getAccessManager()->setTokens(*tokens); m_pendingTokens = tokens; - validateTokens(*tokens); + validateCredentials(*tokens); } void NexusConnectionUI::disconnect() { - clearTokens(); + clearCredentials(); m_log->clear(); addLog(tr("Disconnected.")); } -void NexusConnectionUI::validateTokens(const NexusOAuthTokens& tokens) +void NexusConnectionUI::validateCredentials(const NexusOAuthTokens& tokens) { if (!m_nexusValidator) { m_nexusValidator.reset(new NexusKeyValidator( @@ -188,9 +188,10 @@ void NexusConnectionUI::validateTokens(const NexusOAuthTokens& tokens) void NexusConnectionUI::onTokensReceived(const NexusOAuthTokens& tokens) { - if (GlobalSettings::hasNexusOAuthTokens()) { + if (GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey()) { NexusOAuthTokens oldTokens; GlobalSettings::nexusOAuthTokens(oldTokens); + GlobalSettings::nexusApiKey(oldTokens.apiKey); NexusOAuthTokens newTokens(tokens); if (tokens.apiKey.isEmpty()) { newTokens.apiKey = oldTokens.apiKey; @@ -207,7 +208,7 @@ void NexusConnectionUI::onTokensReceived(const NexusOAuthTokens& tokens) m_pendingTokens = tokens; } addLog(tr("Received authorization from Nexus.")); - validateTokens(tokens); + validateCredentials(tokens); } void NexusConnectionUI::onOAuthStateChanged(NXMAccessManager::OAuthState s, @@ -263,8 +264,9 @@ void NexusConnectionUI::addLog(const QString& s) bool NexusConnectionUI::persistTokens(const NexusOAuthTokens& tokens) { - const bool ret = GlobalSettings::setNexusOAuthTokens(tokens); - if (ret) { + const bool ret = GlobalSettings::setNexusOAuthTokens(tokens); + const bool ret2 = GlobalSettings::setNexusApiKey(tokens.apiKey); + if (ret && ret2) { NexusInterface::instance().getAccessManager()->setTokens(tokens); } @@ -272,19 +274,20 @@ bool NexusConnectionUI::persistTokens(const NexusOAuthTokens& tokens) emit keyChanged(); - return ret; + return ret && ret2; } -bool NexusConnectionUI::clearTokens() +bool NexusConnectionUI::clearCredentials() { - const auto ret = GlobalSettings::clearNexusOAuthTokens(); + auto ret = GlobalSettings::clearNexusOAuthTokens(); + auto ret2 = GlobalSettings::clearNexusApiKey(); - NexusInterface::instance().getAccessManager()->clearTokens(); + NexusInterface::instance().getAccessManager()->clearCredentials(); updateState(); emit keyChanged(); - return ret; + return ret && ret2; } void NexusConnectionUI::updateState() @@ -308,6 +311,7 @@ void NexusConnectionUI::updateState() } else if (GlobalSettings::hasNexusOAuthTokens()) { NexusOAuthTokens tokens; GlobalSettings::nexusOAuthTokens(tokens); + GlobalSettings::nexusApiKey(tokens.apiKey); if (tokens.accessToken.isEmpty()) { setButton(m_connect, true, QObject::tr("Connect to Nexus")); } else { diff --git a/src/settingsdialognexus.h b/src/settingsdialognexus.h index 96980b689..f3033315d 100644 --- a/src/settingsdialognexus.h +++ b/src/settingsdialognexus.h @@ -46,9 +46,9 @@ class NexusConnectionUI : public QObject void updateState(); - void validateTokens(const NexusOAuthTokens& tokens); + void validateCredentials(const NexusOAuthTokens& tokens); bool persistTokens(const NexusOAuthTokens& tokens); - bool clearTokens(); + bool clearCredentials(); void onTokensReceived(const NexusOAuthTokens& tokens); void onOAuthStateChanged(NXMAccessManager::OAuthState s, const QString& message); From ecd704d8967c72592fcf14e61326180353e041b4 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 25 Apr 2026 00:07:33 -0500 Subject: [PATCH 09/11] Rework auth handling - Remove callbacks, implement signal / slots - Revert isValid since the API key is handled separately - Add additional callbacks / error handling - Try to ensure the reply handler is ready --- src/nexusoauthlogin.cpp | 3 --- src/nexusoauthtokens.h | 5 +--- src/nxmaccessmanager.cpp | 48 ++++++++++++++++--------------------- src/nxmaccessmanager.h | 10 ++++---- src/settingsdialognexus.cpp | 25 +++++++++++-------- 5 files changed, 41 insertions(+), 50 deletions(-) diff --git a/src/nexusoauthlogin.cpp b/src/nexusoauthlogin.cpp index 03f14198e..7d13642d6 100644 --- a/src/nexusoauthlogin.cpp +++ b/src/nexusoauthlogin.cpp @@ -57,9 +57,6 @@ void NexusOAuthLogin::start() connect(accessManager, &NXMAccessManager::authorizationEnded, this, &NexusOAuthLogin::authorizationEnded); - accessManager->tokensReceived = tokensReceived; - accessManager->stateChanged = stateChanged; - NexusInterface::instance().getAccessManager()->connectOrRefresh(NexusOAuthTokens()); m_active = true; } diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index b3bc5d53f..9aa5ee378 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -34,10 +34,7 @@ struct NexusOAuthTokens QDateTime expiresAt; QString apiKey; - bool isValid() const - { - return (!accessToken.isEmpty() && expiresAt.isValid()) || !apiKey.isEmpty(); - } + bool isValid() const { return !accessToken.isEmpty() && expiresAt.isValid(); } bool isExpired(std::chrono::seconds skew = std::chrono::seconds(60)) const { diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index 0ed63394b..eab7ee6bd 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -610,6 +610,8 @@ NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, m_Validator(s, *this), m_ValidationState(NotChecked) { m_NexusOAuth.reset(new QOAuth2AuthorizationCodeFlow); + m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( + QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, [&](QAbstractOAuth::Error error) { @@ -620,20 +622,23 @@ NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, notifyTokens(); }); - connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::tokenChanged, this, [&]() { - tokensReceived = [&](const NexusOAuthTokens& tokens) { - saveRefreshedTokens(tokens); - }; - stateChanged = nullptr; - notifyTokens(); - }); - connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, [&](const QUrl& url) { shell::Open(url); setOAuthState(OAuthState::WaitingForBrowser); }); + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::accessTokenAboutToExpire, + this, [&] { + if (!m_NexusOAuthReplyHandler->isListening() && + !m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, + NexusOAuth::redirectPort())) { + handleOAuthError(QObject::tr("Failed to bind to localhost on port %1.") + .arg(NexusOAuth::redirectPort())); + return; + } + }); + connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::statusChanged, this, [&](QAbstractOAuth::Status status) { switch (status) { @@ -651,6 +656,9 @@ NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, } }); + connect(this, &NXMAccessManager::tokensReceived, this, + &NXMAccessManager::saveRefreshedTokens); + m_Validator.finished = [&](auto&& r, auto&& m, auto&& u) { onValidatorFinished(r, m, u); }; @@ -739,19 +747,14 @@ void NXMAccessManager::ensureFreshToken() return; } - tokensReceived = [&](const NexusOAuthTokens& tokens) { - saveRefreshedTokens(tokens); - }; - stateChanged = nullptr; connectOrRefresh(*m_Tokens); } } void NXMAccessManager::handleOAuthError(const QString& message) { - if (stateChanged) { - stateChanged(OAuthState::Error, message); - } + m_NexusOAuthReplyHandler->close(); + emit updateOAuthState(OAuthState::Error, message); emit authorizationEnded(); } @@ -785,9 +788,7 @@ void NXMAccessManager::notifyTokens() tokens.scope = scopes.join(" "); - if (tokensReceived) { - tokensReceived(tokens); - } + emit tokensReceived(tokens); startValidationCheck(tokens); emit authorizationEnded(); @@ -817,9 +818,7 @@ void NXMAccessManager::saveRefreshedTokens(const NexusOAuthTokens tokens) void NXMAccessManager::setOAuthState(OAuthState state, const QString& message) { - if (stateChanged) { - stateChanged(state, message); - } + emit updateOAuthState(state, message); } QString NXMAccessManager::stateToString(OAuthState state, const QString& details) @@ -943,8 +942,6 @@ void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) m_NexusOAuth->setRefreshLeadTime(std::chrono::seconds(300)); QSet scope = {"openid", "profile", "email"}; m_NexusOAuth->setRequestedScopeTokens(scope); - m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( - QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); m_NexusOAuthReplyHandler->setCallbackPath(QUrl(NexusOAuth::redirectUri()).path()); m_NexusOAuthReplyHandler->setCallbackText( QObject::tr("

Mod Organizer

Authorization complete. You " @@ -970,7 +967,6 @@ void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) setOAuthState(OAuthState::Refreshing); m_NexusOAuth->refreshTokens(); } else { - setOAuthState(OAuthState::Initializing); m_NexusOAuth->grant(); } @@ -1073,10 +1069,6 @@ void NXMAccessManager::apiCheck(const NexusOAuthTokens& tokens, bool force) } if (m_NexusOAuth->token().isEmpty() && !tokens.accessToken.isEmpty()) { - tokensReceived = [&](const NexusOAuthTokens& tokens) { - saveRefreshedTokens(tokens); - }; - stateChanged = nullptr; connectOrRefresh(tokens); } else if (!tokens.apiKey.isEmpty()) { startValidationCheck(tokens); diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index 75a2fa758..96b5ec059 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -198,9 +198,6 @@ class NXMAccessManager : public QNetworkAccessManager static QString stateToString(OAuthState state, const QString& details = {}); - std::function tokensReceived; - std::function stateChanged; - void addAPIHeaders(QNetworkRequest& request); void apiCheck(const NexusOAuthTokens& tokens, bool force = false); @@ -237,6 +234,8 @@ class NXMAccessManager : public QNetworkAccessManager void validateFailed(const QString& message); void credentialsReceived(const APIUserAccount& user); void authorizationEnded(); + void tokensReceived(const NexusOAuthTokens tokens); + void updateOAuthState(OAuthState state, QString message); protected: virtual QNetworkReply* createRequest(QNetworkAccessManager::Operation operation, @@ -267,8 +266,6 @@ class NXMAccessManager : public QNetworkAccessManager void setOAuthState(OAuthState state, const QString& message = {}); void notifyTokens(); - void saveRefreshedTokens(const NexusOAuthTokens tokens); - void startValidationCheck(const NexusOAuthTokens& tokens); void onValidatorFinished(ValidationAttempt::Result r, const QString& message, @@ -278,6 +275,9 @@ class NXMAccessManager : public QNetworkAccessManager void startProgress(); void stopProgress(); + +private slots: + void saveRefreshedTokens(const NexusOAuthTokens tokens); }; #endif // NXMACCESSMANAGER_H diff --git a/src/settingsdialognexus.cpp b/src/settingsdialognexus.cpp index f6835797d..673ab844b 100644 --- a/src/settingsdialognexus.cpp +++ b/src/settingsdialognexus.cpp @@ -101,7 +101,19 @@ NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, }); } - if (GlobalSettings::hasNexusOAuthTokens()) { + QObject::connect(NexusInterface::instance().getAccessManager(), + &NXMAccessManager::updateOAuthState, this, + [&](NXMAccessManager::OAuthState state, QString message) { + onOAuthStateChanged(state, message); + }); + + QObject::connect(NexusInterface::instance().getAccessManager(), + &NXMAccessManager::tokensReceived, this, + [&](const NexusOAuthTokens tokens) { + onTokensReceived(tokens); + }); + + if (GlobalSettings::hasNexusOAuthTokens() || GlobalSettings::hasNexusApiKey()) { addLog(tr("Connected.")); } else { addLog(tr("Not connected.")); @@ -119,14 +131,6 @@ void NexusConnectionUI::connect() if (!m_nexusLogin) { m_nexusLogin.reset(new NexusOAuthLogin(m_parent)); - - m_nexusLogin->tokensReceived = [&](const NexusOAuthTokens& tokens) { - onTokensReceived(tokens); - }; - - m_nexusLogin->stateChanged = [&](auto&& state, auto&& message) { - onOAuthStateChanged(state, message); - }; } m_log->clear(); @@ -308,7 +312,8 @@ void NexusConnectionUI::updateState() } else if (m_nexusValidator && m_nexusValidator->isActive()) { setButton(m_connect, false, QObject::tr("Connect to Nexus")); setButton(m_disconnect, false); - } else if (GlobalSettings::hasNexusOAuthTokens()) { + } else if (GlobalSettings::hasNexusOAuthTokens() || + GlobalSettings::hasNexusApiKey()) { NexusOAuthTokens tokens; GlobalSettings::nexusOAuthTokens(tokens); GlobalSettings::nexusApiKey(tokens.apiKey); From a3a7e594e31c1884a352706eee04afec7d64b8fc Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 25 Apr 2026 15:33:03 -0500 Subject: [PATCH 10/11] Address startup and init issues --- src/nxmaccessmanager.cpp | 8 ++++++-- src/organizercore.cpp | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index eab7ee6bd..b039f2a4a 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -609,6 +609,10 @@ NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, : QNetworkAccessManager(parent), m_Settings(s), m_MOVersion(moVersion), m_Validator(s, *this), m_ValidationState(NotChecked) { + NexusOAuthTokens tokens; + GlobalSettings::nexusOAuthTokens(tokens); + GlobalSettings::nexusApiKey(tokens.apiKey); + m_Tokens = tokens; m_NexusOAuth.reset(new QOAuth2AuthorizationCodeFlow); m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); @@ -1115,8 +1119,8 @@ void NXMAccessManager::clearCredentials() // request, params.toString(QUrl::FullyEncoded).toUtf8()); //} m_Tokens.reset(); - m_NexusOAuthReplyHandler.reset(); - m_NexusOAuth.reset(); + m_NexusOAuth->setToken(""); + m_NexusOAuthReplyHandler->close(); emit credentialsReceived(APIUserAccount()); } diff --git a/src/organizercore.cpp b/src/organizercore.cpp index bdbd772aa..fe7736e7b 100644 --- a/src/organizercore.cpp +++ b/src/organizercore.cpp @@ -300,9 +300,11 @@ bool OrganizerCore::nexusApi(bool retry) return false; } else { NexusOAuthTokens tokens; - if (GlobalSettings::nexusOAuthTokens(tokens)) { + GlobalSettings::nexusOAuthTokens(tokens); + GlobalSettings::nexusApiKey(tokens.apiKey); + if (tokens.isValid() || !tokens.apiKey.isEmpty()) { // credentials stored or user entered them manually - log::debug("attempt to verify nexus api key"); + log::debug("attempt to verify nexus credentials"); accessManager->apiCheck(tokens); return true; } else { From c4b6bf4ac7a93aae717378a4f4e4a8054d4fadfb Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Wed, 29 Apr 2026 15:46:20 -0500 Subject: [PATCH 11/11] Fixes for token refresh - Make sure validator is properly reset on finish - Move revalidation check to nexusinterface - Stop the API processing until validation clears - Avoids request collisions - Should restart on revalidation - Remove autorefresh for now as it isn't working - Extend refresh time to 5 minutes --- src/nexusinterface.cpp | 4 ++++ src/nexusoauthtokens.h | 2 +- src/nxmaccessmanager.cpp | 29 ++++------------------------- src/nxmaccessmanager.h | 1 - 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/nexusinterface.cpp b/src/nexusinterface.cpp index 113b5dde6..0f9318edd 100644 --- a/src/nexusinterface.cpp +++ b/src/nexusinterface.cpp @@ -996,6 +996,10 @@ void NexusInterface::nextRequest() QNetworkRequest request(url); if (!currentTokens->accessToken.isEmpty()) { + if (currentTokens->isExpired()) { + m_AccessManager->connectOrRefresh(*currentTokens); + return; + } if (postData.object().isEmpty()) { if (!requestIsDelete) { info.m_Reply = m_AccessManager->makeOAuthGetRequest(url); diff --git a/src/nexusoauthtokens.h b/src/nexusoauthtokens.h index 9aa5ee378..78f3f22c2 100644 --- a/src/nexusoauthtokens.h +++ b/src/nexusoauthtokens.h @@ -36,7 +36,7 @@ struct NexusOAuthTokens bool isValid() const { return !accessToken.isEmpty() && expiresAt.isValid(); } - bool isExpired(std::chrono::seconds skew = std::chrono::seconds(60)) const + bool isExpired(std::chrono::seconds skew = std::chrono::seconds(300)) const { if (!expiresAt.isValid()) { return true; diff --git a/src/nxmaccessmanager.cpp b/src/nxmaccessmanager.cpp index b039f2a4a..18d83e145 100644 --- a/src/nxmaccessmanager.cpp +++ b/src/nxmaccessmanager.cpp @@ -599,6 +599,7 @@ void NexusKeyValidator::onAttemptFailure(const ValidationAttempt& a) void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& message, std::optional user) { + m_Attempts.clear(); if (finished) { finished(r, message, user); } @@ -616,6 +617,7 @@ NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, m_NexusOAuth.reset(new QOAuth2AuthorizationCodeFlow); m_NexusOAuthReplyHandler.reset(new QOAuthHttpServerReplyHandler( QHostAddress::LocalHost, NexusOAuth::redirectPort(), this)); + m_NexusOAuth->setReplyHandler(m_NexusOAuthReplyHandler.get()); connect(m_NexusOAuth.get(), &QOAuth2AuthorizationCodeFlow::requestFailed, this, [&](QAbstractOAuth::Error error) { @@ -739,22 +741,6 @@ std::optional NXMAccessManager::tokens() const return m_Tokens; } -void NXMAccessManager::ensureFreshToken() -{ - if (!m_Tokens || (m_Tokens->apiKey.isEmpty() && m_Tokens->accessToken.isEmpty())) { - log::warn("nexus: no OAuth tokens available"); - return; - } - - if (!m_Tokens->accessToken.isEmpty()) { - if (!m_Tokens->isExpired()) { - return; - } - - connectOrRefresh(*m_Tokens); - } -} - void NXMAccessManager::handleOAuthError(const QString& message) { m_NexusOAuthReplyHandler->close(); @@ -942,23 +928,20 @@ void NXMAccessManager::connectOrRefresh(const NexusOAuthTokens tokens) m_NexusOAuth->setTokenUrl(QUrl(NexusOAuth::tokenUrl())); m_NexusOAuth->setClientIdentifier(clientId); m_NexusOAuth->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256); - m_NexusOAuth->setAutoRefresh(true); - m_NexusOAuth->setRefreshLeadTime(std::chrono::seconds(300)); QSet scope = {"openid", "profile", "email"}; m_NexusOAuth->setRequestedScopeTokens(scope); + m_NexusOAuthReplyHandler->close(); m_NexusOAuthReplyHandler->setCallbackPath(QUrl(NexusOAuth::redirectUri()).path()); m_NexusOAuthReplyHandler->setCallbackText( QObject::tr("

Mod Organizer

Authorization complete. You " "may close this " "window.

")); - if (!m_NexusOAuthReplyHandler->isListening() && - !m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, + if (!m_NexusOAuthReplyHandler->listen(QHostAddress::LocalHost, NexusOAuth::redirectPort())) { handleOAuthError(QObject::tr("Failed to bind to localhost on port %1.") .arg(NexusOAuth::redirectPort())); return; } - m_NexusOAuth->setReplyHandler(m_NexusOAuthReplyHandler.get()); if (!tokens.accessToken.isEmpty()) { m_NexusOAuth->setToken(tokens.accessToken); m_NexusOAuth->setRefreshToken(tokens.refreshToken); @@ -1004,7 +987,6 @@ void NXMAccessManager::addAPIHeaders(QNetworkRequest& request) QNetworkReply* NXMAccessManager::makeOAuthGetRequest(const QUrl url) { if (!m_NexusOAuth->token().isEmpty()) { - ensureFreshToken(); QNetworkRequest request(url); m_NexusOAuth->prepareRequest(&request, "GET"); addAPIHeaders(request); @@ -1017,7 +999,6 @@ QNetworkReply* NXMAccessManager::makeOAuthPostRequest(const QUrl url, const QByteArray payload = {}) { if (!m_NexusOAuth->token().isEmpty()) { - ensureFreshToken(); QNetworkRequest request(url); m_NexusOAuth->prepareRequest(&request, "POST", payload); addAPIHeaders(request); @@ -1029,7 +1010,6 @@ QNetworkReply* NXMAccessManager::makeOAuthPostRequest(const QUrl url, QNetworkReply* NXMAccessManager::makeOAuthDeleteRequest(QNetworkRequest request) { if (!m_NexusOAuth->token().isEmpty()) { - ensureFreshToken(); m_NexusOAuth->prepareRequest(&request, "DELETE"); addAPIHeaders(request); return m_NexusOAuth->networkAccessManager()->deleteResource(request); @@ -1042,7 +1022,6 @@ QNetworkReply* NXMAccessManager::makeOAuthCustomRequest(QNetworkRequest request, const QByteArray& data) { if (!m_NexusOAuth->token().isEmpty()) { - ensureFreshToken(); m_NexusOAuth->prepareRequest(&request, verb, data); addAPIHeaders(request); return m_NexusOAuth->networkAccessManager()->sendCustomRequest(request, verb, data); diff --git a/src/nxmaccessmanager.h b/src/nxmaccessmanager.h index 96b5ec059..ca96deb8a 100644 --- a/src/nxmaccessmanager.h +++ b/src/nxmaccessmanager.h @@ -260,7 +260,6 @@ class NXMAccessManager : public QNetworkAccessManager std::unique_ptr m_NexusOAuth; std::unique_ptr m_NexusOAuthReplyHandler; - void ensureFreshToken(); void handleOAuthError(const QString& message); void setOAuthState(OAuthState state, const QString& message = {});