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:
220
tests/oauth_token_tests.cpp
Normal file
220
tests/oauth_token_tests.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user