#include "nocal/sync/oauth_tokens.hpp" #include "nocal/sync/secret_store.hpp" #include "nocal/sync/token_broker.hpp" #include #include #include #include #include #include #include #include #include namespace { using nocal::sync::AccountDisconnected; using nocal::sync::HttpHeader; using nocal::sync::HttpRequest; using nocal::sync::HttpResponse; using nocal::sync::HttpTransport; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthTokens; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string(message)); } } // Fake OAuthSecretStore with scriptable behavior class ScriptedSecretStore final : public OAuthSecretStore { public: struct LoadResult { std::optional tokens; bool throw_on_load{false}; std::string load_error_message{}; }; struct StoreResult { bool throw_on_store{false}; std::string store_error_message{}; }; struct EraseResult { bool throw_on_erase{false}; std::string erase_error_message{}; }; std::map store_data; std::map load_script; std::map store_script; std::map erase_script; std::size_t load_calls{0}; std::size_t store_calls{0}; std::size_t erase_calls{0}; void store(std::string account_id, const OAuthTokens& tokens, std::stop_token = {}) override { ++store_calls; const auto found = store_script.find(account_id); if (found != store_script.end() && found->second.throw_on_store) { throw std::runtime_error(found->second.store_error_message); } store_data[std::move(account_id)] = tokens; } [[nodiscard]] std::optional load( std::string account_id, std::stop_token = {}) override { ++load_calls; const auto found = load_script.find(account_id); if (found != load_script.end() && found->second.throw_on_load) { throw std::runtime_error(found->second.load_error_message); } const auto stored = store_data.find(account_id); if (stored != store_data.end()) { return stored->second; } if (found != load_script.end() && found->second.tokens.has_value()) { return found->second.tokens; } return std::nullopt; } void erase(std::string account_id, std::stop_token = {}) override { ++erase_calls; const auto found = erase_script.find(account_id); if (found != erase_script.end() && found->second.throw_on_erase) { throw std::runtime_error(found->second.erase_error_message); } store_data.erase(account_id); } }; // Fake HttpTransport with scripted responses class ScriptedTransport final : public HttpTransport { public: struct ResponseSpec { int status{200}; std::string body{}; bool throw_on_send{false}; std::string send_error_message{}; }; std::vector requests; std::vector responses; std::size_t fail_at{static_cast(-1)}; HttpResponse send(const HttpRequest& request) override { requests.push_back(request); const std::size_t index = requests.size() - 1; if (index == fail_at) { throw std::runtime_error("transport failure"); } if (index >= responses.size()) { return {200, {}, "{}"}; } const ResponseSpec& spec = responses[index]; if (spec.throw_on_send) { throw std::runtime_error(spec.send_error_message); } return {spec.status, {}, spec.body}; } }; // Controllable clock class ControllableClock { public: std::int64_t value{0}; [[nodiscard]] std::int64_t operator()() { return value; } }; OAuthClientConfig make_config(bool with_scopes = true) { OAuthClientConfig config; config.client_id = "client-id"; config.token_endpoint = "https://example.com/token"; if (with_scopes) { config.scopes = {"openid", "profile"}; } return config; } OAuthTokens make_tokens(std::int64_t expires_at) { OAuthTokens tokens; tokens.access_token = "access-token"; tokens.refresh_token = "refresh+token&with=special"; tokens.token_type = "Bearer"; tokens.scope = "openid profile"; tokens.expires_at_epoch_seconds = expires_at; return tokens; } // Helper to build a 200 OK token response std::string token_response(std::string_view access, std::string_view refresh, std::int64_t expires_in, std::string_view scope = {}) { std::string body = "{\"access_token\":\"" + std::string(access) + "\",\"refresh_token\":\"" + std::string(refresh) + "\",\"token_type\":\"Bearer\",\"expires_in\":" + std::to_string(expires_in); if (!scope.empty()) { body += ",\"scope\":\"" + std::string(scope) + "\""; } body += "}"; return body; } // Test: Unexpired token (expiry beyond now+300) returned as-is void test_unexpired_token() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; // Store tokens expiring at 1000 + 301 = 1301 (just beyond skew) store.store_data["test-account"] = make_tokens(1301); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); const OAuthTokens tokens = broker.valid_tokens("test-account"); require(tokens.access_token == "access-token", "wrong access token returned"); require(tokens.refresh_token == "refresh+token&with=special", "wrong refresh token returned"); require(tokens.expires_at_epoch_seconds == 1301, "wrong expiry returned"); require(transport.requests.empty(), "HTTP request made for unexpired token"); require(store.store_calls == 0, "store was called for unexpired token"); require(store.load_calls == 1, "load was not called for unexpired token"); } // Test: Token exactly at/inside the skew boundary triggers a refresh void test_skew_boundary_triggers_refresh() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; // Store tokens expiring at 1000 + 300 = 1300 (exactly at skew) store.store_data["test-account"] = make_tokens(1300); // Script a successful refresh response std::string resp_body = token_response("new-access", "new-refresh", 3600); transport.responses.push_back({200, resp_body, false, ""}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); const OAuthTokens tokens = broker.valid_tokens("test-account"); require(tokens.access_token == "new-access", "wrong access token after refresh"); require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh"); require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh"); require(transport.requests.size() == 1, "HTTP request not made for expired token"); require(store.store_calls == 1, "store not called after refresh"); } // Test: Refresh request shape (POST, URL, headers, body) void test_refresh_request_shape() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; // Tokens about to expire store.store_data["test-account"] = make_tokens(1200); // Script a successful refresh transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)}); OAuthClientConfig config = make_config(true); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); static_cast(broker.valid_tokens("test-account")); require(transport.requests.size() == 1, "wrong number of requests"); const HttpRequest& req = transport.requests.front(); require(req.method == nocal::sync::HttpMethod::post, "wrong HTTP method"); require(req.url == "https://example.com/token", "wrong token endpoint URL"); require(req.headers.size() == 1, "wrong number of headers"); require(req.headers[0].name == "Content-Type", "wrong header name"); require(req.headers[0].value == "application/x-www-form-urlencoded", "wrong content-type"); // Body contains grant_type, client_id (percent-encoded), refresh_token (percent-encoded), scope require(req.body.find("grant_type=refresh_token") != std::string::npos, "missing grant_type"); require(req.body.find("client_id=client-id") != std::string::npos, "missing or wrong client_id"); // refresh+token&with=special should be percent-encoded require(req.body.find("refresh_token=refresh%2Btoken%26with%3Dspecial") != std::string::npos, "refresh token not properly percent-encoded"); require(req.body.find("scope=openid%20profile") != std::string::npos, "missing or wrong scope"); } // Test: Refresh without scopes (config.scopes empty) void test_refresh_request_no_scopes() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)}); OAuthClientConfig config = make_config(false); // no scopes nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); static_cast(broker.valid_tokens("test-account")); const HttpRequest& req = transport.requests.front(); require(req.body.find("scope=") == std::string::npos, "scope present when config.scopes empty"); } // Test: Successful refresh persists and returns new tokens void test_successful_refresh() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); const OAuthTokens tokens = broker.valid_tokens("test-account"); require(tokens.access_token == "new-access", "wrong access token after refresh"); require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh"); require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh"); // Verify store was updated const auto stored = store.store_data.find("test-account"); require(stored != store.store_data.end(), "tokens not persisted"); require(stored->second.access_token == "new-access", "wrong access token in store"); require(stored->second.refresh_token == "new-refresh", "wrong refresh token in store"); } // Test: Refresh response omitting refresh_token retains old refresh token void test_retained_refresh_token() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); // Response without refresh_token field (not present) transport.responses.push_back({200, "{\"access_token\":\"new-access\",\"token_type\":\"Bearer\",\"expires_in\":3600}", false, ""}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); const OAuthTokens tokens = broker.valid_tokens("test-account"); require(tokens.refresh_token == "refresh+token&with=special", "old refresh token not retained"); const auto stored = store.store_data.find("test-account"); require(stored != store.store_data.end(), "tokens not persisted"); require(stored->second.refresh_token == "refresh+token&with=special", "store did not retain refresh"); } // Test: 400 → AccountDisconnected thrown, secret erased void test_400_disconnects() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({400, "invalid_grant"}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); try { static_cast(broker.valid_tokens("test-account")); throw std::runtime_error("400 did not throw AccountDisconnected"); } catch (const AccountDisconnected& e) { require(std::string(e.what()).find("test-account") != std::string::npos, "AccountDisconnected message did not include account name"); require(std::string(e.what()).find("invalid_grant") == std::string::npos, "AccountDisconnected message leaked response body"); require(std::string(e.what()).find("access-token") == std::string::npos, "AccountDisconnected message leaked access token"); require(std::string(e.what()).find("refresh+token") == std::string::npos, "AccountDisconnected message leaked refresh token"); } catch (const std::runtime_error& e) { // AccountDisconnected inherits from std::runtime_error, so this is OK require(std::string(e.what()).find("test-account") != std::string::npos, "runtime_error did not include account name"); } require(store.erase_calls == 1, "secret not erased on 400"); require(store.store_data.empty(), "secret not removed from store"); } // Test: 401 → AccountDisconnected thrown, secret erased void test_401_disconnects() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({401, "Unauthorized"}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("test-account")); } catch (const AccountDisconnected&) { caught = true; } require(caught, "401 did not throw AccountDisconnected"); require(store.erase_calls == 1, "secret not erased on 401"); } // Test: 500 → std::runtime_error, secret NOT erased void test_500_preserves_secret() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({500, "Internal Server Error"}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { caught = true; require(std::string(e.what()).find("500") != std::string::npos, "error message did not include status code"); require(std::string(e.what()).find("Internal Server Error") == std::string::npos, "error message leaked response body"); } require(caught, "500 did not throw"); require(store.erase_calls == 0, "secret erased on 500"); require(!store.store_data.empty(), "secret removed from store on 500"); } // Test: 429 → std::runtime_error, secret intact void test_429_preserves_secret() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({429, "Too Many Requests"}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { caught = true; require(std::string(e.what()).find("429") != std::string::npos, "error message did not include status code 429"); } require(caught, "429 did not throw"); require(store.erase_calls == 0, "secret erased on 429"); } // Test: Transport throws → propagates, secret intact void test_transport_throws() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.fail_at = 0; OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { caught = true; require(std::string(e.what()).find("transport failure") != std::string::npos, "error message did not include transport failure"); } require(caught, "transport throw not propagated"); require(store.erase_calls == 0, "secret erased on transport error"); } // Test: Missing secret (nullopt) → std::runtime_error, zero HTTP requests void test_missing_secret() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; // Empty store, no tokens for "missing-account" OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("missing-account")); } catch (const std::runtime_error& e) { caught = true; require(std::string(e.what()).find("test-account") == std::string::npos, "error message leaked account name"); require(std::string(e.what()).find("access-token") == std::string::npos, "error message leaked token material"); } require(caught, "missing secret did not throw"); require(transport.requests.empty(), "HTTP request made for missing secret"); require(store.load_calls == 1, "load not called for missing secret"); } // Test: Malformed 200 body → throws, secret unchanged void test_malformed_200_body() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({200, "not-json", false, ""}); OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { caught = true; require(std::string(e.what()).find("parse error") != std::string::npos, "error message did not indicate parse error"); } require(caught, "malformed body not rejected"); require(store.erase_calls == 0, "secret erased on parse error"); require(store.store_calls == 0, "secret overwritten on parse error"); require(!store.store_data.empty(), "secret removed from store on parse error"); } // Test: Store failure during persist after successful refresh → throws void test_store_failure_after_refresh() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; store.store_data["test-account"] = make_tokens(1200); transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)}); // Script store to throw on the expected store call store.store_script["test-account"].throw_on_store = true; store.store_script["test-account"].store_error_message = "store failed"; OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); bool caught = false; try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { caught = true; require(std::string(e.what()).find("store error") != std::string::npos, "error message did not indicate store error"); } require(caught, "store failure not propagated"); // Tokens should not be returned require(store.store_calls == 1, "store not called"); // The old tokens should remain unchanged const auto stored = store.store_data.find("test-account"); require(stored != store.store_data.end(), "old tokens removed"); require(stored->second.access_token == "access-token", "old access token changed"); } // Test: No exception message contains token values (various failure paths) void test_token_redaction_in_errors() { ControllableClock clock; clock.value = 1000; ScriptedSecretStore store; ScriptedTransport transport; // Use tokens with distinctive markers OAuthTokens tokens; tokens.access_token = "ACCESS_TOKEN_MARKER"; tokens.refresh_token = "REFRESH_TOKEN_MARKER"; tokens.token_type = "Bearer"; tokens.scope = "openid"; tokens.expires_at_epoch_seconds = 1200; store.store_data["test-account"] = tokens; // Test 1: Transport error transport.fail_at = 0; { OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos, "transport error leaked access token"); require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos, "transport error leaked refresh token"); } } // Test 2: 500 response transport.fail_at = static_cast(-1); transport.responses.clear(); transport.responses.push_back({500, "body with ACCESS_TOKEN_MARKER and REFRESH_TOKEN_MARKER"}); { OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos, "500 error leaked access token"); require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos, "500 error leaked refresh token"); } } // Test 3: Malformed response transport.responses.clear(); transport.responses.push_back({200, "ACCESS_TOKEN_MARKER REFRESH_TOKEN_MARKER not-json", false, ""}); { OAuthClientConfig config = make_config(); nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); }); try { static_cast(broker.valid_tokens("test-account")); } catch (const std::runtime_error& e) { require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos, "parse error leaked access token"); require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos, "parse error leaked refresh token"); } } } } // namespace int main() { try { test_unexpired_token(); test_skew_boundary_triggers_refresh(); test_refresh_request_shape(); test_refresh_request_no_scopes(); test_successful_refresh(); test_retained_refresh_token(); test_400_disconnects(); test_401_disconnects(); test_500_preserves_secret(); test_429_preserves_secret(); test_transport_throws(); test_missing_secret(); test_malformed_200_body(); test_store_failure_after_refresh(); test_token_redaction_in_errors(); } catch (const std::exception& error) { std::cerr << "token broker tests failed: " << error.what() << '\n'; return 1; } std::cout << "token broker tests passed\n"; return 0; }