Files
nocal/src/sync/oauth_tokens.cpp
Bernardo Magri 3560a7d23d feat: store OAuth tokens securely
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.
2026-07-18 11:56:04 +01:00

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