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.
This commit is contained in:
2026-07-18 11:56:04 +01:00
parent 295acbc125
commit 3560a7d23d
13 changed files with 1405 additions and 27 deletions

220
tests/oauth_token_tests.cpp Normal file
View File

@@ -0,0 +1,220 @@
#include "nocal/sync/oauth_tokens.hpp"
#include <cstdint>
#include <exception>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
namespace {
using nocal::sync::OAuthTokens;
using nocal::sync::parse_oauth_token_response;
constexpr std::size_t maximum_response_size = 1024U * 1024U;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string(message));
}
}
template <typename Action>
std::string expect_failure(Action&& action) {
try {
action();
} catch (const std::exception& error) {
return error.what();
}
throw std::runtime_error("invalid OAuth token response was accepted");
}
[[nodiscard]] std::string token_body(std::string_view extra = {}) {
return std::string{"{\"access_token\":\"access\",\"refresh_token\":\"refresh\","}
+ "\"token_type\":\"Bearer\",\"expires_in\":3600" + std::string(extra) + "}";
}
void test_success_and_unknown_fields() {
const OAuthTokens tokens = parse_oauth_token_response(
token_body(",\"scope\":\"openid offline_access\",\"unknown\":{\"nested\":true}"),
1'000);
require(tokens
== OAuthTokens{"access", "refresh", "Bearer", "openid offline_access", 4'600},
"valid token response was not parsed exactly");
const OAuthTokens lower = parse_oauth_token_response(
"{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"bEaReR\","
"\"expires_in\":1}",
-2);
require(lower.token_type == "Bearer" && lower.expires_at_epoch_seconds == -1,
"Bearer comparison/canonicalization or negative epoch arithmetic failed");
}
void test_retained_and_rotated_refresh_tokens() {
const OAuthTokens retained = parse_oauth_token_response(
"{\"access_token\":\"new-access\",\"token_type\":\"Bearer\",\"expires_in\":60}",
100, "old-refresh");
require(retained.refresh_token == "old-refresh" && retained.expires_at_epoch_seconds == 160,
"refresh response did not retain the prior refresh token");
const OAuthTokens rotated = parse_oauth_token_response(
"{\"access_token\":\"new-access\",\"refresh_token\":\"new-refresh\","
"\"token_type\":\"Bearer\",\"expires_in\":60}",
100, "old-refresh");
require(rotated.refresh_token == "new-refresh",
"rotated refresh token did not replace the retained token");
expect_failure([&] {
(void)parse_oauth_token_response(
"{\"access_token\":\"a\",\"token_type\":\"Bearer\",\"expires_in\":1}",
0);
});
expect_failure([&] {
(void)parse_oauth_token_response(
"{\"access_token\":\"a\",\"refresh_token\":\"\","
"\"token_type\":\"Bearer\",\"expires_in\":1}",
0, "retained");
});
}
void test_expiration_boundaries_and_integral_types() {
const auto maximum = std::numeric_limits<std::int64_t>::max();
const auto minimum = std::numeric_limits<std::int64_t>::min();
require(parse_oauth_token_response(token_body(",\"scope\":\"s\""), maximum - 3'600)
.expires_at_epoch_seconds
== maximum,
"maximum signed expiration boundary was rejected");
require(parse_oauth_token_response(
"{\"access_token\":\"a\",\"refresh_token\":\"r\","
"\"token_type\":\"Bearer\",\"expires_in\":18446744073709551615}",
minimum)
.expires_at_epoch_seconds
== maximum,
"full unsigned expiration boundary was not added safely");
require(parse_oauth_token_response(
"{\"access_token\":\"a\",\"refresh_token\":\"r\","
"\"token_type\":\"Bearer\",\"expires_in\":1}",
minimum)
.expires_at_epoch_seconds
== minimum + 1,
"minimum signed epoch boundary was not added safely");
expect_failure([&] { (void)parse_oauth_token_response(token_body(), maximum); });
for (const std::string expires : {"0", "-1", "1.0", "1e3", "\"1\"", "true", "null",
"18446744073709551616"}) {
const std::string body =
"{\"access_token\":\"a\",\"refresh_token\":\"r\","
"\"token_type\":\"Bearer\",\"expires_in\":"
+ expires + "}";
expect_failure([&] { (void)parse_oauth_token_response(body, 0); });
}
}
void test_wrong_types_and_required_values() {
const std::vector<std::string> invalid = {"{}", "[]", "null", "true",
"{\"access_token\":1,\"refresh_token\":\"r\",\"token_type\":\"Bearer\","
"\"expires_in\":1}",
"{\"access_token\":\"\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\","
"\"expires_in\":1}",
"{\"access_token\":\"a\",\"refresh_token\":1,\"token_type\":\"Bearer\","
"\"expires_in\":1}",
"{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":1,"
"\"expires_in\":1}",
"{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"Basic\","
"\"expires_in\":1}",
"{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\","
"\"expires_in\":1,\"scope\":\"\"}",
"{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\","
"\"expires_in\":1,\"scope\":[]}"};
for (const std::string& body : invalid) {
expect_failure([&] { (void)parse_oauth_token_response(body, 0); });
}
expect_failure([&] {
(void)parse_oauth_token_response(
"{\"access_token\":\"a\\n\",\"refresh_token\":\"r\","
"\"token_type\":\"Bearer\",\"expires_in\":1}", 0);
});
}
void test_duplicate_keys_at_every_depth() {
const std::vector<std::string> duplicates = {
"{\"access_token\":\"first\",\"access_token\":\"second\","
"\"refresh_token\":\"r\",\"token_type\":\"Bearer\",\"expires_in\":1}",
"{\"access_token\":\"a\",\"refresh_token\":\"r\","
"\"token_type\":\"Bearer\",\"expires_in\":1,\"unknown\":1,\"unknown\":2}",
"{\"access_token\":\"a\",\"refresh_token\":\"r\","
"\"token_type\":\"Bearer\",\"expires_in\":1,"
"\"unknown\":{\"duplicate\":1,\"duplicate\":2}}"};
for (const std::string& body : duplicates) {
expect_failure([&] { (void)parse_oauth_token_response(body, 0); });
}
}
void test_invalid_json_utf8_size_and_redaction() {
std::vector<std::string> invalid = {"{", "not-json"};
invalid.push_back(std::string("{\"access_token\":\"") + static_cast<char>(0xff)
+ "\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\",\"expires_in\":1}");
for (const std::string& body : invalid) {
const std::string message = expect_failure(
[&] { (void)parse_oauth_token_response(body, 0); });
require(message.find(body) == std::string::npos,
"parser error leaked the invalid response body");
}
const std::string prefix =
"{\"access_token\":\"BOUNDARY_ACCESS_TOKEN\","
"\"refresh_token\":\"BOUNDARY_REFRESH_TOKEN\","
"\"token_type\":\"Bearer\",\"expires_in\":1,\"padding\":\"";
const std::string suffix = "\"}";
std::string exact = prefix;
exact.append(maximum_response_size - prefix.size() - suffix.size(), 'x');
exact += suffix;
require(parse_oauth_token_response(exact, 0).access_token == "BOUNDARY_ACCESS_TOKEN",
"exactly 1 MiB token response was rejected");
std::string oversized = exact;
oversized.push_back(' ');
const std::string oversized_message =
expect_failure([&] { (void)parse_oauth_token_response(oversized, 0); });
require(oversized_message.find("BOUNDARY") == std::string::npos,
"size parser error leaked token material");
const std::string malicious_body =
"{\"access_token\":\"MALICIOUS_ACCESS_TOKEN\","
"\"refresh_token\":\"MALICIOUS_REFRESH_TOKEN\","
"\"token_type\":\"Basic\",\"expires_in\":1}";
const std::string malicious_message =
expect_failure([&] { (void)parse_oauth_token_response(malicious_body, 0); });
require(malicious_message.find("MALICIOUS") == std::string::npos,
"semantic parser error leaked token material");
const std::string retained_secret = "MALICIOUS_RETAINED_TOKEN";
const std::string retained_message = expect_failure([&] {
(void)parse_oauth_token_response(
"{\"access_token\":\"a\",\"token_type\":\"Basic\",\"expires_in\":1}",
0, retained_secret);
});
require(retained_message.find(retained_secret) == std::string::npos,
"parser error leaked retained refresh token");
}
} // namespace
int main() {
try {
test_success_and_unknown_fields();
test_retained_and_rotated_refresh_tokens();
test_expiration_boundaries_and_integral_types();
test_wrong_types_and_required_values();
test_duplicate_keys_at_every_depth();
test_invalid_json_utf8_size_and_redaction();
} catch (const std::exception& error) {
std::cerr << "OAuth token tests failed: " << error.what() << '\n';
return 1;
}
std::cout << "OAuth token tests passed\n";
return 0;
}