/* * Strawberry Music Player * Copyright 2022-2025, Jonas Kvinge * * Strawberry 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. * * Strawberry 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 Strawberry. If not, see . * */ #include "config.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "constants/timeconstants.h" #include "utilities/randutils.h" #include "logging.h" #include "settings.h" #include "networkaccessmanager.h" #include "localredirectserver.h" #include "oauthenticator.h" using namespace Qt::Literals::StringLiterals; using std::make_shared; using namespace std::chrono_literals; namespace { constexpr char kTokenType[] = "token_type"; constexpr char kAccessToken[] = "access_token"; constexpr char kRefreshToken[] = "refresh_token"; constexpr char kExpiresIn[] = "expires_in"; constexpr char kLoginTime[] = "login_time"; constexpr char kUserId[] = "user_id"; constexpr char kCountryCode[] = "country_code"; constexpr int kMaxPortInc = 20; } // namespace OAuthenticator::OAuthenticator(const SharedPtr network, QObject *parent) : QObject(parent), network_(network), timer_refresh_login_(new QTimer(this)), type_(Type::Authorization_Code), use_local_redirect_server_(true), random_port_(true), expires_in_(0LL), login_time_(0LL), user_id_(0) { timer_refresh_login_->setSingleShot(true); QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &OAuthenticator::RenewAccessToken); } OAuthenticator::~OAuthenticator() { while (!replies_.isEmpty()) { QNetworkReply *reply = replies_.takeFirst(); QObject::disconnect(reply, nullptr, this, nullptr); reply->abort(); reply->deleteLater(); } } void OAuthenticator::set_settings_group(const QString &settings_group) { settings_group_ = settings_group; } void OAuthenticator::set_type(const Type type) { type_ = type; } void OAuthenticator::set_authorize_url(const QUrl &authorize_url) { authorize_url_ = authorize_url; } void OAuthenticator::set_redirect_url(const QUrl &redirect_url) { redirect_url_ = redirect_url; } void OAuthenticator::set_access_token_url(const QUrl &access_token_url) { access_token_url_ = access_token_url; } void OAuthenticator::set_client_id(const QString &client_id) { client_id_ = client_id; } void OAuthenticator::set_client_secret(const QString &client_secret) { client_secret_ = client_secret; } void OAuthenticator::set_scope(const QString &scope) { scope_ = scope; } void OAuthenticator::set_use_local_redirect_server(const bool use_local_redirect_server) { use_local_redirect_server_ = use_local_redirect_server; } void OAuthenticator::set_random_port(const bool random_port) { random_port_ = random_port; } QByteArray OAuthenticator::authorization_header() const { if (token_type_.isEmpty() || access_token_.isEmpty()) { return QByteArray(); } return token_type().toUtf8() + " " + access_token().toUtf8(); } QString OAuthenticator::GrantType() const { switch (type_) { case Type::Authorization_Code: return u"authorization_code"_s; break; case Type::Client_Credentials: return u"client_credentials"_s; break; } return QString(); } void OAuthenticator::LoadSession() { Settings s; s.beginGroup(settings_group_); token_type_ = s.value(kTokenType).toString(); access_token_ = s.value(kAccessToken).toString(); refresh_token_ = s.value(kRefreshToken).toString(); expires_in_ = s.value(kExpiresIn, 0LL).toLongLong(); login_time_ = s.value(kLoginTime, 0LL).toLongLong(); country_code_ = s.value(kCountryCode).toString(); user_id_ = s.value(kUserId).toInt(); s.endGroup(); StartRefreshLoginTimer(); } void OAuthenticator::ClearSession() { token_type_.clear(); access_token_.clear(); refresh_token_.clear(); expires_in_ = 0; login_time_ = 0; country_code_.clear(); user_id_ = 0; Settings s; s.beginGroup(settings_group_); s.remove(kTokenType); s.remove(kAccessToken); s.remove(kRefreshToken); s.remove(kExpiresIn); s.remove(kLoginTime); s.remove(kCountryCode); s.remove(kUserId); s.endGroup(); if (timer_refresh_login_->isActive()) { timer_refresh_login_->stop(); } } void OAuthenticator::StartRefreshLoginTimer() { if (login_time_ > 0 && !refresh_token_.isEmpty() && expires_in_ > 0) { const qint64 time = std::max(1LL, expires_in_ - (QDateTime::currentSecsSinceEpoch() - login_time_)); qLog(Debug) << settings_group_ << "Refreshing login in" << time << "seconds"; timer_refresh_login_->setInterval(static_cast(time * kMsecPerSec)); if (!timer_refresh_login_->isActive()) { timer_refresh_login_->start(); } } } void OAuthenticator::Authenticate() { if (type_ == Type::Client_Credentials) { RequestAccessToken(); return; } QUrl redirect_url(redirect_url_); if (use_local_redirect_server_) { local_redirect_server_.reset(new LocalRedirectServer(this)); bool success = false; if (random_port_) { success = local_redirect_server_->Listen(); } else { const int max_port = redirect_url.port() + kMaxPortInc; for (int port = redirect_url.port(); port < max_port; ++port) { local_redirect_server_->set_port(port); if (local_redirect_server_->Listen()) { success = true; break; } } } if (!success) { Q_EMIT AuthenticationFinished(false, local_redirect_server_->error()); local_redirect_server_.reset(); return; } QObject::connect(&*local_redirect_server_, &LocalRedirectServer::Finished, this, &OAuthenticator::RedirectArrived); redirect_url.setPort(local_redirect_server_->port()); } code_verifier_ = Utilities::CryptographicRandomString(44); code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding)); if (code_challenge_.lastIndexOf(u'=') == code_challenge_.length() - 1) { code_challenge_.chop(1); } ParamList params = ParamList() << Param(u"response_type"_s, u"code"_s) << Param(u"redirect_uri"_s, redirect_url.toString()) << Param(u"state"_s, code_challenge_) << Param(u"code_challenge_method"_s, u"S256"_s) << Param(u"code_challenge"_s, code_challenge_); if (!client_id_.isEmpty()) { params << Param(u"client_id"_s, client_id_); } if (!scope_.isEmpty()) { params << Param(u"scope"_s, scope_); } std::sort(params.begin(), params.end()); QUrlQuery url_query; for (const Param ¶m : std::as_const(params)) { url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first, ";")), QString::fromLatin1(QUrl::toPercentEncoding(param.second, ";"))); } QUrl url(authorize_url_); url.setQuery(url_query); const bool success = QDesktopServices::openUrl(url); if (!success) { QMessageBox messagebox(QMessageBox::Information, tr("Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":
%1").arg(url.toString()), QMessageBox::Ok); messagebox.setTextFormat(Qt::RichText); messagebox.exec(); } } QNetworkReply *OAuthenticator::CreateRequest(const ParamList ¶ms) { QUrlQuery url_query; for (const Param ¶m : std::as_const(params)) { url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second))); } QNetworkRequest network_request(access_token_url_); network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); network_request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s); if (type_ == Type::Client_Credentials && !client_id_.isEmpty() && !client_secret_.isEmpty()) { const QString authorization_header = client_id_ + u':' + client_secret_; network_request.setRawHeader("Authorization", "Basic " + authorization_header.toUtf8().toBase64()); } QNetworkReply *reply = network_->post(network_request, url_query.toString(QUrl::FullyEncoded).toUtf8()); replies_ << reply; QObject::connect(reply, &QNetworkReply::sslErrors, this, &OAuthenticator::HandleLoginSSLErrors); QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); }); return reply; } void OAuthenticator::RedirectArrived() { if (local_redirect_server_.isNull()) { return; } if (local_redirect_server_->success()) { QUrl redirect_url(redirect_url_); redirect_url.setPort(local_redirect_server_->port()); AuthorizationUrlReceived(local_redirect_server_->request_url(), redirect_url); } else { Q_EMIT AuthenticationFinished(false, local_redirect_server_->error()); } local_redirect_server_.reset(); } void OAuthenticator::ExternalAuthorizationUrlReceived(const QUrl &request_url) { AuthorizationUrlReceived(request_url, redirect_url_); } void OAuthenticator::AuthorizationUrlReceived(const QUrl &request_url, const QUrl &redirect_url) { if (!request_url.isValid()) { Q_EMIT AuthenticationFinished(false, tr("Received invalid reply from web browser.")); return; } if (!request_url.hasQuery()) { Q_EMIT AuthenticationFinished(false, tr("Redirect URL is missing query.")); return; } qLog(Debug) << settings_group_ << "Authorization URL Received" << request_url.toDisplayString(); QUrlQuery url_query(request_url); if (url_query.hasQueryItem(u"error_description"_s)) { Q_EMIT AuthenticationFinished(false, url_query.queryItemValue(u"error_description"_s, QUrl::FullyDecoded)); return; } if (url_query.hasQueryItem(u"error"_s)) { Q_EMIT AuthenticationFinished(false, url_query.queryItemValue(u"error"_s)); return; } if (!url_query.hasQueryItem(u"code"_s)) { Q_EMIT AuthenticationFinished(false, tr("Request URL is missing code!")); return; } if (!url_query.hasQueryItem(u"state"_s)) { Q_EMIT AuthenticationFinished(false, tr("Request URL is missing state!")); return; } if (url_query.queryItemValue(u"state"_s) != code_challenge_) { Q_EMIT AuthenticationFinished(false, tr("Request URL has wrong state %1 != %2").arg(url_query.queryItemValue(u"state"_s), code_challenge_)); return; } RequestAccessToken(url_query.queryItemValue(u"code"_s), redirect_url); } void OAuthenticator::RequestAccessToken(const QString &code, const QUrl &redirect_url) { if (timer_refresh_login_->isActive()) { timer_refresh_login_->stop(); } ParamList params = ParamList() << Param(u"grant_type"_s, GrantType()); if (!code.isEmpty()) { params << Param(u"code"_s, code); } if (!code_verifier_.isEmpty()) { params << Param(u"code_verifier"_s, code_verifier_); } if (!code.isEmpty()) { params << Param(u"redirect_uri"_s, redirect_url.toString()); } if (!client_id_.isEmpty()) { params << Param(u"client_id"_s, client_id_); } if (!client_secret_.isEmpty()) { params << Param(u"client_secret"_s, client_secret_); } std::sort(params.begin(), params.end()); CreateRequest(params); } void OAuthenticator::RenewAccessToken() { if (timer_refresh_login_->isActive()) { timer_refresh_login_->stop(); } if (client_id_.isEmpty() || refresh_token_.isEmpty()) { return; } ParamList params = ParamList() << Param(u"grant_type"_s, u"refresh_token"_s) << Param(u"client_id"_s, client_id_) << Param(u"refresh_token"_s, refresh_token_); if (!client_secret_.isEmpty()) { params << Param(u"client_secret"_s, client_secret_); } CreateRequest(params); } void OAuthenticator::HandleLoginSSLErrors(const QList &ssl_errors) { for (const QSslError &ssl_error : ssl_errors) { qLog(Debug) << settings_group_ << ssl_error.errorString(); } } void OAuthenticator::AccessTokenRequestFinished(QNetworkReply *reply) { if (!replies_.contains(reply)) return; replies_.removeAll(reply); QObject::disconnect(reply, nullptr, this, nullptr); reply->deleteLater(); if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { const QString error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()); Q_EMIT AuthenticationFinished(false, error_message); } if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { const QByteArray data = reply->readAll(); if (!data.isEmpty()) { QJsonParseError json_error; const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error); if (json_error.error == QJsonParseError::NoError && !json_document.isEmpty() && json_document.isObject()) { const QJsonObject json_object = json_document.object(); if (json_object.contains("error"_L1) && json_object.contains("error_description"_L1)) { const QString error = json_object["error"_L1].toString(); const QString error_description = json_object["error_description"_L1].toString(); Q_EMIT AuthenticationFinished(false, QStringLiteral("%1 (%2)").arg(error, error_description)); return; } qLog(Debug) << settings_group_ << "Unknown Json reply" << json_object; } } if (reply->error() == QNetworkReply::NoError) { Q_EMIT AuthenticationFinished(false, QStringLiteral("Received HTTP status code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt())); } else { Q_EMIT AuthenticationFinished(false, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error())); } return; } const QByteArray data = reply->readAll(); QJsonParseError json_error; const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error); if (json_error.error != QJsonParseError::NoError) { Q_EMIT AuthenticationFinished(false, QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString())); return; } if (json_document.isEmpty()) { Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has empty Json document."_s); return; } if (!json_document.isObject()) { Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has Json document that is not an object."_s); return; } const QJsonObject json_object = json_document.object(); if (json_object.isEmpty()) { Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has empty Json object."_s); return; } if (!json_object.contains("token_type"_L1)) { Q_EMIT AuthenticationFinished(false, u"Authentication reply from server is missing token type."_s); return; } if (!json_object.contains("access_token"_L1)) { Q_EMIT AuthenticationFinished(false, u"Authentication reply from server is missing access token."_s); return; } token_type_ = json_object["token_type"_L1].toString(); access_token_ = json_object["access_token"_L1].toString(); if (json_object.contains("refresh_token"_L1)) { refresh_token_ = json_object["refresh_token"_L1].toString(); } else { refresh_token_.clear(); } if (json_object.contains("expires_in"_L1)) { expires_in_ = json_object["expires_in"_L1].toInt(); } else { expires_in_ = 0; } login_time_ = QDateTime::currentSecsSinceEpoch(); country_code_.clear(); user_id_ = 0; if (json_object.contains("user"_L1) && json_object["user"_L1].isObject()) { const QJsonObject object_user = json_object["user"_L1].toObject(); if (object_user.contains("countryCode"_L1) && object_user.contains("userId"_L1)) { country_code_ = object_user["countryCode"_L1].toString(); user_id_ = object_user["userId"_L1].toInt(); } } Settings s; s.beginGroup(settings_group_); s.setValue(kTokenType, token_type_); s.setValue(kAccessToken, access_token_); s.setValue(kLoginTime, login_time_); if (refresh_token_.isEmpty()) { s.remove(kRefreshToken); } else { s.setValue(kRefreshToken, refresh_token_); } if (expires_in_ == 0) { s.remove(kExpiresIn); } else { s.setValue(kExpiresIn, expires_in_); } if (country_code_.isEmpty()) { s.remove(kCountryCode); } else { s.setValue(kCountryCode, country_code_); } if (user_id_ == 0) { s.remove(kUserId); } else { s.setValue(kUserId, user_id_); } s.endGroup(); StartRefreshLoginTimer(); qLog(Debug) << settings_group_ << "Authentication was successful, login expires in" << expires_in_; Q_EMIT AuthenticationFinished(true); }