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.
221 lines
9.0 KiB
C++
221 lines
9.0 KiB
C++
#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;
|
|
}
|