#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "katabasis/OAuth2.h" #include "katabasis/PollServer.h" #include "katabasis/ReplyServer.h" #include "katabasis/Globals.h" #include "JsonResponse.h" namespace { // ref: https://tools.ietf.org/html/rfc8628#section-3.2 // Exception: Google sign-in uses "verification_url" instead of "*_uri" - we'll accept both. bool hasMandatoryDeviceAuthParams(const QVariantMap& params) { if (!params.contains(Katabasis::OAUTH2_DEVICE_CODE)) return false; if (!params.contains(Katabasis::OAUTH2_USER_CODE)) return false; if (!(params.contains(Katabasis::OAUTH2_VERIFICATION_URI) || params.contains(Katabasis::OAUTH2_VERIFICATION_URL))) return false; if (!params.contains(Katabasis::OAUTH2_EXPIRES_IN)) return false; return true; } QByteArray createQueryParameters(const QList ¶meters) { QByteArray ret; bool first = true; for( auto & h: parameters) { if (first) { first = false; } else { ret.append("&"); } ret.append(QUrl::toPercentEncoding(h.name) + "=" + QUrl::toPercentEncoding(h.value)); } return ret; } } namespace Katabasis { OAuth2::OAuth2(Options & opts, Token & token, QObject *parent, QNetworkAccessManager *manager) : QObject(parent), token_(token) { manager_ = manager ? manager : new QNetworkAccessManager(this); grantFlow_ = GrantFlowAuthorizationCode; qRegisterMetaType("QNetworkReply::NetworkError"); options_ = opts; } bool OAuth2::linked() { return token_.validity != Validity::None; } void OAuth2::setLinked(bool v) { qDebug() << "OAuth2::setLinked:" << (v? "true": "false"); token_.validity = v ? Validity::Certain : Validity::None; } QString OAuth2::token() { return token_.token; } void OAuth2::setToken(const QString &v) { token_.token = v; } QByteArray OAuth2::replyContent() const { return replyContent_; } void OAuth2::setReplyContent(const QByteArray &value) { replyContent_ = value; if (replyServer_) { replyServer_->setReplyContent(replyContent_); } } QVariantMap OAuth2::extraTokens() { return token_.extra; } void OAuth2::setExtraTokens(QVariantMap extraTokens) { token_.extra = extraTokens; } void OAuth2::setReplyServer(ReplyServer * server) { delete replyServer_; replyServer_ = server; replyServer_->setReplyContent(replyContent_); } ReplyServer * OAuth2::replyServer() const { return replyServer_; } void OAuth2::setPollServer(PollServer *server) { if (pollServer_) pollServer_->deleteLater(); pollServer_ = server; } PollServer *OAuth2::pollServer() const { return pollServer_; } OAuth2::GrantFlow OAuth2::grantFlow() { return grantFlow_; } void OAuth2::setGrantFlow(OAuth2::GrantFlow value) { grantFlow_ = value; } QString OAuth2::username() { return username_; } void OAuth2::setUsername(const QString &value) { username_ = value; } QString OAuth2::password() { return password_; } void OAuth2::setPassword(const QString &value) { password_ = value; } QVariantMap OAuth2::extraRequestParams() { return extraReqParams_; } void OAuth2::setExtraRequestParams(const QVariantMap &value) { extraReqParams_ = value; } QString OAuth2::grantType() { if (!grantType_.isEmpty()) return grantType_; switch (grantFlow_) { case GrantFlowAuthorizationCode: return OAUTH2_GRANT_TYPE_CODE; case GrantFlowImplicit: return OAUTH2_GRANT_TYPE_TOKEN; case GrantFlowResourceOwnerPasswordCredentials: return OAUTH2_GRANT_TYPE_PASSWORD; case GrantFlowDevice: return OAUTH2_GRANT_TYPE_DEVICE; } return QString(); } void OAuth2::setGrantType(const QString &value) { grantType_ = value; } void OAuth2::updateActivity(Activity activity) { if(activity_ != activity) { activity_ = activity; emit activityChanged(activity_); } } void OAuth2::link() { qDebug() << "OAuth2::link"; // Create the reply server if it doesn't exist if(replyServer() == NULL) { ReplyServer * replyServer = new ReplyServer(this); connect(replyServer, &ReplyServer::verificationReceived, this, &OAuth2::onVerificationReceived); connect(replyServer, &ReplyServer::serverClosed, this, &OAuth2::serverHasClosed); setReplyServer(replyServer); } if (linked()) { qDebug() << "OAuth2::link: Linked already"; emit linkingSucceeded(); return; } setLinked(false); setToken(""); setExtraTokens(QVariantMap()); setRefreshToken(QString()); setExpires(QDateTime()); if (grantFlow_ == GrantFlowAuthorizationCode || grantFlow_ == GrantFlowImplicit) { QString uniqueState = QUuid::createUuid().toString().remove(QRegExp("([^a-zA-Z0-9]|[-])")); // FIXME: this should be part of a 'redirection handler' that would get injected into O2 { quint16 foundPort = 0; // Start listening to authentication replies if (!replyServer()->isListening()) { auto ports = options_.listenerPorts; for(auto & port: ports) { if (replyServer()->listen(QHostAddress::Any, port)) { foundPort = replyServer()->serverPort(); qDebug() << "OAuth2::link: Reply server listening on port " << foundPort; break; } } if(foundPort == 0) { qWarning() << "OAuth2::link: Reply server failed to start listening on any port out of " << ports; emit linkingFailed(); return; } } // Save redirect URI, as we have to reuse it when requesting the access token redirectUri_ = options_.redirectionUrl.arg(foundPort); replyServer()->setUniqueState(uniqueState); } // Assemble intial authentication URL QUrl url(options_.authorizationUrl); QUrlQuery query(url); QList > parameters; query.addQueryItem(OAUTH2_RESPONSE_TYPE, (grantFlow_ == GrantFlowAuthorizationCode)? OAUTH2_GRANT_TYPE_CODE: OAUTH2_GRANT_TYPE_TOKEN); query.addQueryItem(OAUTH2_CLIENT_ID, options_.clientIdentifier); query.addQueryItem(OAUTH2_REDIRECT_URI, redirectUri_); query.addQueryItem(OAUTH2_SCOPE, options_.scope.replace( " ", "+" )); query.addQueryItem(OAUTH2_STATE, uniqueState); if (!apiKey_.isEmpty()) { query.addQueryItem(OAUTH2_API_KEY, apiKey_); } for(auto iter = extraReqParams_.begin(); iter != extraReqParams_.end(); iter++) { query.addQueryItem(iter.key(), iter.value().toString()); } url.setQuery(query); // Show authentication URL with a web browser qDebug() << "OAuth2::link: Emit openBrowser" << url.toString(); emit openBrowser(url); updateActivity(Activity::LoggingIn); } else if (grantFlow_ == GrantFlowResourceOwnerPasswordCredentials) { QList parameters; parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8())); if ( !options_.clientSecret.isEmpty() ) { parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8())); } parameters.append(RequestParameter(OAUTH2_USERNAME, username_.toUtf8())); parameters.append(RequestParameter(OAUTH2_PASSWORD, password_.toUtf8())); parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, OAUTH2_GRANT_TYPE_PASSWORD)); parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8())); if ( !apiKey_.isEmpty() ) parameters.append(RequestParameter(OAUTH2_API_KEY, apiKey_.toUtf8())); foreach (QString key, extraRequestParams().keys()) { parameters.append(RequestParameter(key.toUtf8(), extraRequestParams().value(key).toByteArray())); } QByteArray payload = createQueryParameters(parameters); qDebug() << "OAuth2::link: Sending token request for resource owner flow"; QUrl url(options_.accessTokenUrl); QNetworkRequest tokenRequest(url); tokenRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); QNetworkReply *tokenReply = manager_->post(tokenRequest, payload); connect(tokenReply, SIGNAL(finished()), this, SLOT(onTokenReplyFinished()), Qt::QueuedConnection); connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection); updateActivity(Activity::LoggingIn); } else if (grantFlow_ == GrantFlowDevice) { QList parameters; parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8())); parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8())); QByteArray payload = createQueryParameters(parameters); QUrl url(options_.authorizationUrl); QNetworkRequest deviceRequest(url); deviceRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); QNetworkReply *tokenReply = manager_->post(deviceRequest, payload); connect(tokenReply, SIGNAL(finished()), this, SLOT(onDeviceAuthReplyFinished()), Qt::QueuedConnection); connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection); updateActivity(Activity::LoggingIn); } } void OAuth2::unlink() { qDebug() << "OAuth2::unlink"; updateActivity(Activity::LoggingOut); // FIXME: implement logout flows... if they exist token_ = Token(); updateActivity(Activity::Idle); } void OAuth2::onVerificationReceived(const QMap response) { qDebug() << "OAuth2::onVerificationReceived: Emitting closeBrowser()"; emit closeBrowser(); if (response.contains("error")) { qWarning() << "OAuth2::onVerificationReceived: Verification failed:" << response; emit linkingFailed(); updateActivity(Activity::Idle); return; } if (grantFlow_ == GrantFlowAuthorizationCode) { // NOTE: access code is temporary and should never be saved anywhere! auto access_code = response.value(QString(OAUTH2_GRANT_TYPE_CODE)); // Exchange access code for access/refresh tokens QString query; if(!apiKey_.isEmpty()) query = QString("?" + QString(OAUTH2_API_KEY) + "=" + apiKey_); QNetworkRequest tokenRequest(QUrl(options_.accessTokenUrl.toString() + query)); tokenRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM); tokenRequest.setRawHeader("Accept", MIME_TYPE_JSON); QMap parameters; parameters.insert(OAUTH2_GRANT_TYPE_CODE, access_code); parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier); if ( !options_.clientSecret.isEmpty() ) { parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret); } parameters.insert(OAUTH2_REDIRECT_URI, redirectUri_); parameters.insert(OAUTH2_GRANT_TYPE, AUTHORIZATION_CODE); QByteArray data = buildRequestBody(parameters); qDebug() << QString("OAuth2::onVerificationReceived: Exchange access code data:\n%1").arg(QString(data)); QNetworkReply *tokenReply = manager_->post(tokenRequest, data); timedReplies_.add(tokenReply); connect(tokenReply, SIGNAL(finished()), this, SLOT(onTokenReplyFinished()), Qt::QueuedConnection); connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection); } else if (grantFlow_ == GrantFlowImplicit || grantFlow_ == GrantFlowDevice) { // Check for mandatory tokens if (response.contains(OAUTH2_ACCESS_TOKEN)) { qDebug() << "OAuth2::onVerificationReceived: Access token returned for implicit or device flow"; setToken(response.value(OAUTH2_ACCESS_TOKEN)); if (response.contains(OAUTH2_EXPIRES_IN)) { bool ok = false; int expiresIn = response.value(OAUTH2_EXPIRES_IN).toInt(&ok); if (ok) { qDebug() << "OAuth2::onVerificationReceived: Token expires in" << expiresIn << "seconds"; setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn)); } } if (response.contains(OAUTH2_REFRESH_TOKEN)) { setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN)); } setLinked(true); emit linkingSucceeded(); } else { qWarning() << "OAuth2::onVerificationReceived: Access token missing from response for implicit or device flow"; emit linkingFailed(); } updateActivity(Activity::Idle); } else { setToken(response.value(OAUTH2_ACCESS_TOKEN)); setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN)); updateActivity(Activity::Idle); } } void OAuth2::onTokenReplyFinished() { qDebug() << "OAuth2::onTokenReplyFinished"; QNetworkReply *tokenReply = qobject_cast(sender()); if (!tokenReply) { qDebug() << "OAuth2::onTokenReplyFinished: reply is null"; return; } if (tokenReply->error() == QNetworkReply::NoError) { QByteArray replyData = tokenReply->readAll(); // Dump replyData // SENSITIVE DATA in RelWithDebInfo or Debug builds //qDebug() << "OAuth2::onTokenReplyFinished: replyData\n"; //qDebug() << QString( replyData ); QVariantMap tokens = parseJsonResponse(replyData); // Dump tokens qDebug() << "OAuth2::onTokenReplyFinished: Tokens returned:\n"; foreach (QString key, tokens.keys()) { // SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first qDebug() << key << ": "<< tokens.value( key ).toString(); } // Check for mandatory tokens if (tokens.contains(OAUTH2_ACCESS_TOKEN)) { qDebug() << "OAuth2::onTokenReplyFinished: Access token returned"; setToken(tokens.take(OAUTH2_ACCESS_TOKEN).toString()); bool ok = false; int expiresIn = tokens.take(OAUTH2_EXPIRES_IN).toInt(&ok); if (ok) { qDebug() << "OAuth2::onTokenReplyFinished: Token expires in" << expiresIn << "seconds"; setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn)); } setRefreshToken(tokens.take(OAUTH2_REFRESH_TOKEN).toString()); setExtraTokens(tokens); timedReplies_.remove(tokenReply); setLinked(true); emit linkingSucceeded(); } else { qWarning() << "OAuth2::onTokenReplyFinished: Access token missing from response"; emit linkingFailed(); } } tokenReply->deleteLater(); updateActivity(Activity::Idle); } void OAuth2::onTokenReplyError(QNetworkReply::NetworkError error) { QNetworkReply *tokenReply = qobject_cast(sender()); if (!tokenReply) { qDebug() << "OAuth2::onTokenReplyError: reply is null"; } else { qWarning() << "OAuth2::onTokenReplyError: " << error << ": " << tokenReply->errorString(); qDebug() << "OAuth2::onTokenReplyError: " << tokenReply->readAll(); timedReplies_.remove(tokenReply); } setToken(QString()); setRefreshToken(QString()); emit linkingFailed(); } QByteArray OAuth2::buildRequestBody(const QMap ¶meters) { QByteArray body; bool first = true; foreach (QString key, parameters.keys()) { if (first) { first = false; } else { body.append("&"); } QString value = parameters.value(key); body.append(QUrl::toPercentEncoding(key) + QString("=").toUtf8() + QUrl::toPercentEncoding(value)); } return body; } QDateTime OAuth2::expires() { return token_.notAfter; } void OAuth2::setExpires(QDateTime v) { token_.notAfter = v; } void OAuth2::startPollServer(const QVariantMap ¶ms) { bool ok = false; int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok); if (!ok) { qWarning() << "OAuth2::startPollServer: No expired_in parameter"; emit linkingFailed(); return; } qDebug() << "OAuth2::startPollServer: device_ and user_code expires in" << expiresIn << "seconds"; QUrl url(options_.accessTokenUrl); QNetworkRequest authRequest(url); authRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); const QString deviceCode = params[OAUTH2_DEVICE_CODE].toString(); const QString grantType = grantType_.isEmpty() ? OAUTH2_GRANT_TYPE_DEVICE : grantType_; QList parameters; parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8())); if ( !options_.clientSecret.isEmpty() ) { parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8())); } parameters.append(RequestParameter(OAUTH2_CODE, deviceCode.toUtf8())); parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, grantType.toUtf8())); QByteArray payload = createQueryParameters(parameters); PollServer * pollServer = new PollServer(manager_, authRequest, payload, expiresIn, this); if (params.contains(OAUTH2_INTERVAL)) { int interval = params[OAUTH2_INTERVAL].toInt(&ok); if (ok) pollServer->setInterval(interval); } connect(pollServer, SIGNAL(verificationReceived(QMap)), this, SLOT(onVerificationReceived(QMap))); connect(pollServer, SIGNAL(serverClosed(bool)), this, SLOT(serverHasClosed(bool))); setPollServer(pollServer); pollServer->startPolling(); } QString OAuth2::refreshToken() { return token_.refresh_token; } void OAuth2::setRefreshToken(const QString &v) { #ifndef NDEBUG qDebug() << "OAuth2::setRefreshToken" << v << "..."; #endif token_.refresh_token = v; } bool OAuth2::refresh() { qDebug() << "OAuth2::refresh: Token: ..." << refreshToken().right(7); if (refreshToken().isEmpty()) { qWarning() << "OAuth2::refresh: No refresh token"; onRefreshError(QNetworkReply::AuthenticationRequiredError); return false; } if (options_.accessTokenUrl.isEmpty()) { qWarning() << "OAuth2::refresh: Refresh token URL not set"; onRefreshError(QNetworkReply::AuthenticationRequiredError); return false; } updateActivity(Activity::Refreshing); QNetworkRequest refreshRequest(options_.accessTokenUrl); refreshRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM); QMap parameters; parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier); if ( !options_.clientSecret.isEmpty() ) { parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret); } parameters.insert(OAUTH2_REFRESH_TOKEN, refreshToken()); parameters.insert(OAUTH2_GRANT_TYPE, OAUTH2_REFRESH_TOKEN); QByteArray data = buildRequestBody(parameters); QNetworkReply *refreshReply = manager_->post(refreshRequest, data); timedReplies_.add(refreshReply); connect(refreshReply, SIGNAL(finished()), this, SLOT(onRefreshFinished()), Qt::QueuedConnection); connect(refreshReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRefreshError(QNetworkReply::NetworkError)), Qt::QueuedConnection); return true; } void OAuth2::onRefreshFinished() { QNetworkReply *refreshReply = qobject_cast(sender()); if (refreshReply->error() == QNetworkReply::NoError) { QByteArray reply = refreshReply->readAll(); QVariantMap tokens = parseJsonResponse(reply); setToken(tokens.value(OAUTH2_ACCESS_TOKEN).toString()); setExpires(QDateTime::currentDateTimeUtc().addSecs(tokens.value(OAUTH2_EXPIRES_IN).toInt())); QString refreshToken = tokens.value(OAUTH2_REFRESH_TOKEN).toString(); if(!refreshToken.isEmpty()) { setRefreshToken(refreshToken); } else { qDebug() << "No new refresh token. Keep the old one."; } timedReplies_.remove(refreshReply); setLinked(true); emit linkingSucceeded(); emit refreshFinished(QNetworkReply::NoError); qDebug() << "New token expires in" << expires() << "seconds"; } else { qDebug() << "OAuth2::onRefreshFinished: Error" << (int)refreshReply->error() << refreshReply->errorString(); } refreshReply->deleteLater(); updateActivity(Activity::Idle); } void OAuth2::onRefreshError(QNetworkReply::NetworkError error) { QNetworkReply *refreshReply = qobject_cast(sender()); qWarning() << "OAuth2::onRefreshError: " << error; unlink(); timedReplies_.remove(refreshReply); emit refreshFinished(error); } void OAuth2::onDeviceAuthReplyFinished() { qDebug() << "OAuth2::onDeviceAuthReplyFinished"; QNetworkReply *tokenReply = qobject_cast(sender()); if (!tokenReply) { qDebug() << "OAuth2::onDeviceAuthReplyFinished: reply is null"; return; } if (tokenReply->error() == QNetworkReply::NoError) { QByteArray replyData = tokenReply->readAll(); // Dump replyData // SENSITIVE DATA in RelWithDebInfo or Debug builds //qDebug() << "OAuth2::onDeviceAuthReplyFinished: replyData\n"; //qDebug() << QString( replyData ); QVariantMap params = parseJsonResponse(replyData); // Dump tokens qDebug() << "OAuth2::onDeviceAuthReplyFinished: Tokens returned:\n"; foreach (QString key, params.keys()) { // SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first qDebug() << key << ": "<< params.value( key ).toString(); } // Check for mandatory parameters if (hasMandatoryDeviceAuthParams(params)) { qDebug() << "OAuth2::onDeviceAuthReplyFinished: Device auth request response"; const QString userCode = params.take(OAUTH2_USER_CODE).toString(); QUrl uri = params.take(OAUTH2_VERIFICATION_URI).toUrl(); if (uri.isEmpty()) uri = params.take(OAUTH2_VERIFICATION_URL).toUrl(); if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE)) emit openBrowser(params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl()); emit showVerificationUriAndCode(uri, userCode); startPollServer(params); } else { qWarning() << "OAuth2::onDeviceAuthReplyFinished: Mandatory parameters missing from response"; emit linkingFailed(); updateActivity(Activity::Idle); } } tokenReply->deleteLater(); } void OAuth2::serverHasClosed(bool paramsfound) { if ( !paramsfound ) { // server has probably timed out after receiving first response emit linkingFailed(); } // poll server is not re-used for later auth requests setPollServer(NULL); } QString OAuth2::apiKey() { return apiKey_; } void OAuth2::setApiKey(const QString &value) { apiKey_ = value; } bool OAuth2::ignoreSslErrors() { return timedReplies_.ignoreSslErrors(); } void OAuth2::setIgnoreSslErrors(bool ignoreSslErrors) { timedReplies_.setIgnoreSslErrors(ignoreSslErrors); } }