Add bounded token response parsing with refresh-token retention and a cancellable worker-thread libsecret adapter using versioned credential payloads. Verify CRUD, isolation, cancellation, corruption, unavailable service, redaction, and cleanup against an isolated D-Bus and gnome-keyring Secret Service in normal, warning-as-error, and sanitizer profiles.
189 lines
6.4 KiB
C++
189 lines
6.4 KiB
C++
#include "nocal/sync/oauth_tokens.hpp"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <unordered_set>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace nocal::sync {
|
|
namespace {
|
|
|
|
constexpr std::size_t maximum_response_size = 1024U * 1024U;
|
|
|
|
class DuplicateKey final : public std::exception {};
|
|
|
|
[[noreturn]] void invalid_response() {
|
|
throw std::invalid_argument("invalid OAuth token response");
|
|
}
|
|
|
|
[[nodiscard]] bool contains_ascii_control(std::string_view value) {
|
|
return std::ranges::any_of(value, [](unsigned char character) {
|
|
return character < 0x20U || character == 0x7fU;
|
|
});
|
|
}
|
|
|
|
[[nodiscard]] bool ascii_iequals(std::string_view left, std::string_view right) {
|
|
if (left.size() != right.size()) {
|
|
return false;
|
|
}
|
|
for (std::size_t index = 0; index < left.size(); ++index) {
|
|
const auto lower = [](unsigned char value) {
|
|
return value >= 'A' && value <= 'Z' ? static_cast<unsigned char>(value + ('a' - 'A'))
|
|
: value;
|
|
};
|
|
if (lower(static_cast<unsigned char>(left[index]))
|
|
!= lower(static_cast<unsigned char>(right[index]))) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
[[nodiscard]] nlohmann::json parse_unique_object(std::string_view response_body) {
|
|
if (response_body.empty() || response_body.size() > maximum_response_size) {
|
|
invalid_response();
|
|
}
|
|
std::vector<std::unordered_set<std::string>> object_keys;
|
|
const nlohmann::json::parser_callback_t callback =
|
|
[&object_keys](int, nlohmann::json::parse_event_t event, nlohmann::json& parsed) {
|
|
if (event == nlohmann::json::parse_event_t::object_start) {
|
|
object_keys.emplace_back();
|
|
} else if (event == nlohmann::json::parse_event_t::key) {
|
|
if (object_keys.empty()
|
|
|| !object_keys.back().insert(parsed.get<std::string>()).second) {
|
|
throw DuplicateKey{};
|
|
}
|
|
} else if (event == nlohmann::json::parse_event_t::object_end) {
|
|
if (object_keys.empty()) {
|
|
throw DuplicateKey{};
|
|
}
|
|
object_keys.pop_back();
|
|
}
|
|
return true;
|
|
};
|
|
try {
|
|
nlohmann::json parsed = nlohmann::json::parse(
|
|
response_body.begin(), response_body.end(), callback, true, false);
|
|
if (!object_keys.empty() || !parsed.is_object()) {
|
|
invalid_response();
|
|
}
|
|
return parsed;
|
|
} catch (const DuplicateKey&) {
|
|
invalid_response();
|
|
} catch (const nlohmann::json::exception&) {
|
|
invalid_response();
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] std::string required_nonempty_string(
|
|
const nlohmann::json& object, std::string_view name) {
|
|
const auto found = object.find(std::string(name));
|
|
if (found == object.end() || !found->is_string()) {
|
|
invalid_response();
|
|
}
|
|
const std::string value = found->get<std::string>();
|
|
if (value.empty() || contains_ascii_control(value)) {
|
|
invalid_response();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
[[nodiscard]] std::string optional_nonempty_string(
|
|
const nlohmann::json& object, std::string_view name) {
|
|
const auto found = object.find(std::string(name));
|
|
if (found == object.end()) {
|
|
return {};
|
|
}
|
|
if (!found->is_string()) {
|
|
invalid_response();
|
|
}
|
|
const std::string value = found->get<std::string>();
|
|
if (value.empty() || contains_ascii_control(value)) {
|
|
invalid_response();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
[[nodiscard]] std::uint64_t positive_integral_seconds(const nlohmann::json& object) {
|
|
const auto found = object.find("expires_in");
|
|
if (found == object.end()) {
|
|
invalid_response();
|
|
}
|
|
if (found->is_number_unsigned()) {
|
|
const std::uint64_t value = found->get<std::uint64_t>();
|
|
if (value == 0) {
|
|
invalid_response();
|
|
}
|
|
return value;
|
|
}
|
|
if (found->is_number_integer()) {
|
|
const std::int64_t value = found->get<std::int64_t>();
|
|
if (value <= 0) {
|
|
invalid_response();
|
|
}
|
|
return static_cast<std::uint64_t>(value);
|
|
}
|
|
invalid_response();
|
|
}
|
|
|
|
[[nodiscard]] std::int64_t add_expiration(
|
|
std::int64_t now_epoch_seconds, std::uint64_t expires_in) {
|
|
constexpr std::int64_t maximum = std::numeric_limits<std::int64_t>::max();
|
|
if (now_epoch_seconds >= 0) {
|
|
const auto room = static_cast<std::uint64_t>(maximum - now_epoch_seconds);
|
|
if (expires_in > room) {
|
|
invalid_response();
|
|
}
|
|
return now_epoch_seconds + static_cast<std::int64_t>(expires_in);
|
|
}
|
|
|
|
const std::uint64_t negative_magnitude =
|
|
static_cast<std::uint64_t>(-(now_epoch_seconds + 1)) + 1U;
|
|
const std::uint64_t room = static_cast<std::uint64_t>(maximum) + negative_magnitude;
|
|
if (expires_in > room) {
|
|
invalid_response();
|
|
}
|
|
if (expires_in >= negative_magnitude) {
|
|
return static_cast<std::int64_t>(expires_in - negative_magnitude);
|
|
}
|
|
const std::uint64_t remaining_magnitude = negative_magnitude - expires_in;
|
|
if (remaining_magnitude == (std::uint64_t{1} << 63U)) {
|
|
return std::numeric_limits<std::int64_t>::min();
|
|
}
|
|
return -static_cast<std::int64_t>(remaining_magnitude);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
OAuthTokens parse_oauth_token_response(std::string_view response_body,
|
|
std::int64_t now_epoch_seconds, std::string_view retained_refresh_token) {
|
|
const nlohmann::json object = parse_unique_object(response_body);
|
|
OAuthTokens result;
|
|
result.access_token = required_nonempty_string(object, "access_token");
|
|
const std::string token_type = required_nonempty_string(object, "token_type");
|
|
if (!ascii_iequals(token_type, "Bearer")) {
|
|
invalid_response();
|
|
}
|
|
result.token_type = "Bearer";
|
|
result.scope = optional_nonempty_string(object, "scope");
|
|
result.refresh_token = optional_nonempty_string(object, "refresh_token");
|
|
if (result.refresh_token.empty()) {
|
|
if (retained_refresh_token.empty() || contains_ascii_control(retained_refresh_token)) {
|
|
invalid_response();
|
|
}
|
|
result.refresh_token = std::string(retained_refresh_token);
|
|
}
|
|
result.expires_at_epoch_seconds =
|
|
add_expiration(now_epoch_seconds, positive_integral_seconds(object));
|
|
return result;
|
|
}
|
|
|
|
} // namespace nocal::sync
|