From 23bcb20ab5e0c0968aca0745038cca1d26c2c72f Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Mon, 20 Jul 2026 22:50:54 +0100 Subject: [PATCH] feat(sync): add provider contracts and OAuth token broker Draft the generic SyncProvider/SyncObserver contracts and implement OAuthTokenBroker: valid access tokens with a five-minute refresh skew, rotation persisted before use so a new refresh token is never lost, 400/401 refresh rejections erase the secret and surface AccountDisconnected, and no credential material in error messages. --- docs/ROADMAP.md | 12 + include/nocal/sync/provider.hpp | 56 +++ include/nocal/sync/token_broker.hpp | 58 +++ meson.build | 4 + src/sync/token_broker.cpp | 147 +++++++ tests/token_broker_tests.cpp | 642 ++++++++++++++++++++++++++++ 6 files changed, 919 insertions(+) create mode 100644 include/nocal/sync/provider.hpp create mode 100644 include/nocal/sync/token_broker.hpp create mode 100644 src/sync/token_broker.cpp create mode 100644 tests/token_broker_tests.cpp diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fc37981..9f0a847 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -93,6 +93,18 @@ Completed secondary-calendar stable-v1 reconciliation: - Overlapping windows retain events until their last membership is removed - No beta or undocumented per-calendar delta dependency +Completed provider-boundary contracts and token broker: + +- Generic `SyncProvider`/`SyncObserver` contracts for account connect, sync, + and disconnect with staged progress events +- `OAuthTokenBroker` returning valid per-operation access tokens with a + five-minute refresh skew +- Refresh rotation persisted before use so a new refresh token is never lost; + transient refresh failures leave stored credentials untouched +- 400/401 refresh rejections erase the stored secret and surface + `AccountDisconnected` for re-authentication +- Credential material never appears in exception messages + Next provider-boundary slices: - Account setup and CLI/TUI orchestration with live credential integration diff --git a/include/nocal/sync/provider.hpp b/include/nocal/sync/provider.hpp new file mode 100644 index 0000000..41b63f2 --- /dev/null +++ b/include/nocal/sync/provider.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +namespace nocal::sync { + +enum class SyncStage { + authenticating, + refreshing_identity, + discovering_calendars, + syncing_calendar, + completed, +}; + +struct SyncProgressEvent { + SyncStage stage{SyncStage::authenticating}; + std::string account_id; + std::string calendar_name; + std::size_t calendars_completed{0}; + std::size_t calendars_total{0}; +}; + +class SyncObserver { +public: + virtual ~SyncObserver() = default; + virtual void on_sync_progress(const SyncProgressEvent& event) = 0; +}; + +struct ConnectedAccount { + std::string account_id; + std::string display_name; +}; + +class SyncProvider { +public: + virtual ~SyncProvider() = default; + + [[nodiscard]] virtual std::string_view provider_id() const noexcept = 0; + + // Interactive connect: runs provider auth, stores credentials, registers + // account. Must not leave partial connected state on failure. + virtual ConnectedAccount connect_account() = 0; + + // Syncs one account's calendars and windows; reports progress via observer. + // May continue past individual calendar failures but reports them. + virtual void sync_account(std::string_view account_id, SyncObserver& observer) = 0; + + // Removes credentials for the account. Cached provider data is left in place + // (deterministic IDs make re-add idempotent). Unknown account is an error. + virtual void disconnect_account(std::string_view account_id) = 0; +}; + +} // namespace nocal::sync \ No newline at end of file diff --git a/include/nocal/sync/token_broker.hpp b/include/nocal/sync/token_broker.hpp new file mode 100644 index 0000000..39d1024 --- /dev/null +++ b/include/nocal/sync/token_broker.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include "nocal/sync/oauth.hpp" +#include "nocal/sync/oauth_tokens.hpp" + +#include +#include +#include +#include +#include + +namespace nocal::sync { + +class OAuthSecretStore; +class HttpTransport; + +class AccountDisconnected final : public std::runtime_error { +public: + explicit AccountDisconnected(std::string message) + : std::runtime_error(std::move(message)) {} +}; + +class OAuthTokenBroker { +public: + // Constructs a broker for one OAuth provider configuration. + // `clock` returns the current epoch seconds for expiry calculations. + // It must be monotonic-ish (system_clock is fine). + OAuthTokenBroker(const OAuthClientConfig& config, + HttpTransport& transport, + OAuthSecretStore& secret_store, + std::function clock); + + // Returns a valid access token for the account, refreshing and persisting + // the rotated tokens when the stored access token is expired or within + // 5 minutes of expiry. + // + // Failure semantics: + // - Transport error, 429, 5xx, timeout -> throws std::runtime_error; + // secret store is NOT touched. + // - 400/401 on refresh (invalid_grant, unauthorized_client, etc.) -> + // erases the secret from the store, throws AccountDisconnected + // (re-auth required). + // - Parsing/validation failure on refresh response -> throws + // std::runtime_error; secret is NOT erased (may be transient malformed + // response). + // - Secret store load failure (missing, locked, cancelled) -> throws + // std::runtime_error; no erase. + [[nodiscard]] OAuthTokens valid_tokens(std::string_view account_id); + +private: + const OAuthClientConfig config_; + HttpTransport& transport_; + OAuthSecretStore& secret_store_; + std::function clock_; + static constexpr std::int64_t refresh_skew_seconds = 300; // 5 min +}; + +} // namespace nocal::sync \ No newline at end of file diff --git a/meson.build b/meson.build index 6e24d35..40ad1df 100644 --- a/meson.build +++ b/meson.build @@ -32,6 +32,7 @@ nocal_sources = files( 'src/sync/oauth.cpp', 'src/sync/oauth_tokens.cpp', 'src/sync/secret_store.cpp', + 'src/sync/token_broker.cpp', 'src/tui/Screen.cpp', 'src/tui/EventEditor.cpp', 'src/tui/Terminal.cpp', @@ -72,6 +73,9 @@ test('desktop-oauth', desktop_oauth_tests) oauth_token_tests = executable('oauth-token-tests', 'tests/oauth_token_tests.cpp', dependencies: nocal_dep) test('oauth-tokens', oauth_token_tests) +token_broker_tests = executable('token-broker-tests', + 'tests/token_broker_tests.cpp', dependencies: nocal_dep) +test('token-broker', token_broker_tests) secret_store_tests = executable('secret-store-tests', 'tests/secret_store_tests.cpp', dependencies: nocal_dep) test('secret-store', shell, diff --git a/src/sync/token_broker.cpp b/src/sync/token_broker.cpp new file mode 100644 index 0000000..25b0011 --- /dev/null +++ b/src/sync/token_broker.cpp @@ -0,0 +1,147 @@ +#include "nocal/sync/token_broker.hpp" + +#include "nocal/sync/oauth.hpp" +#include "nocal/sync/oauth_tokens.hpp" +#include "nocal/sync/secret_store.hpp" + +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +[[nodiscard]] std::string percent_encode(std::string_view value) { + static constexpr char hex[] = "0123456789ABCDEF"; + std::string result; + result.reserve(value.size()); + for (const unsigned char character : value) { + const bool ascii_alphanumeric = (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') + || (character >= '0' && character <= '9'); + if (ascii_alphanumeric || character == '-' || character == '.' || character == '_' + || character == '~') { + result.push_back(static_cast(character)); + } else { + result.push_back('%'); + result.push_back(hex[character >> 4U]); + result.push_back(hex[character & 0x0fU]); + } + } + return result; +} + +[[nodiscard]] std::string join_scopes(const std::vector& scopes) { + std::string result; + for (const std::string& scope : scopes) { + if (!result.empty()) { + result.push_back(' '); + } + result += scope; + } + return result; +} + +void append_parameter( + std::string& output, bool& first, std::string_view name, std::string_view value) { + if (!first) { + output.push_back('&'); + } + first = false; + output += name; + output.push_back('='); + output += percent_encode(value); +} + +[[nodiscard]] std::string make_refresh_body(const OAuthClientConfig& config, + std::string_view refresh_token, const std::string& scope) { + std::string body; + bool first = true; + append_parameter(body, first, "grant_type", "refresh_token"); + append_parameter(body, first, "client_id", config.client_id); + append_parameter(body, first, "refresh_token", refresh_token); + if (!scope.empty()) { + append_parameter(body, first, "scope", scope); + } + return body; +} + +} // namespace + +OAuthTokenBroker::OAuthTokenBroker(const OAuthClientConfig& config, + HttpTransport& transport, OAuthSecretStore& secret_store, + std::function clock) + : config_(config), transport_(transport), secret_store_(secret_store), clock_(clock) {} + +OAuthTokens OAuthTokenBroker::valid_tokens(std::string_view account_id) { + // 1. Load stored tokens + std::optional stored_opt; + try { + stored_opt = secret_store_.load(std::string(account_id)); + } catch (const SecretStoreError&) { + throw; + } catch (const std::exception& e) { + throw std::runtime_error(std::string("failed to load tokens: ") + e.what()); + } + + if (!stored_opt.has_value()) { + throw std::runtime_error("account not connected: " + std::string(account_id)); + } + + const OAuthTokens& stored = *stored_opt; + const std::int64_t now = clock_(); + + // 2. Check if token is still valid (beyond now + 300s skew) + if (stored.expires_at_epoch_seconds > now + refresh_skew_seconds) { + return stored; + } + + // 3. Refresh token + const std::string scope = join_scopes(config_.scopes); + const std::string body = make_refresh_body(config_, stored.refresh_token, scope); + + HttpRequest request; + request.method = HttpMethod::post; + request.url = config_.token_endpoint; + request.headers = {{"Content-Type", "application/x-www-form-urlencoded"}}; + request.body = body; + + HttpResponse response; + try { + response = transport_.send(request); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("transport error: ") + e.what()); + } + + // 4. Handle response + if (response.status == 200) { + OAuthTokens parsed; + try { + parsed = parse_oauth_token_response(response.body, now, stored.refresh_token); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("parse error: ") + e.what()); + } + + // Persist first, then return + try { + secret_store_.store(std::string(account_id), parsed); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("store error: ") + e.what()); + } + + return parsed; + } else if (response.status == 400 || response.status == 401) { + try { + secret_store_.erase(std::string(account_id)); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("erase error: ") + e.what()); + } + throw AccountDisconnected("account " + std::string(account_id) + " disconnected"); + } else { + throw std::runtime_error("unexpected status code: " + std::to_string(response.status)); + } +} + +} // namespace nocal::sync diff --git a/tests/token_broker_tests.cpp b/tests/token_broker_tests.cpp new file mode 100644 index 0000000..ad787b4 --- /dev/null +++ b/tests/token_broker_tests.cpp @@ -0,0 +1,642 @@ +#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; +}