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.
This commit is contained in:
2026-07-20 22:50:54 +01:00
parent 98e07e287e
commit 23bcb20ab5
6 changed files with 919 additions and 0 deletions

View File

@@ -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

View File

@@ -0,0 +1,56 @@
#pragma once
#include <functional>
#include <optional>
#include <string>
#include <string_view>
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

View File

@@ -0,0 +1,58 @@
#pragma once
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include <functional>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
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<std::int64_t()> 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<std::int64_t()> clock_;
static constexpr std::int64_t refresh_skew_seconds = 300; // 5 min
};
} // namespace nocal::sync

View File

@@ -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,

147
src/sync/token_broker.cpp Normal file
View File

@@ -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 <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
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<char>(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<std::string>& 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<std::int64_t()> 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<OAuthTokens> 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

View File

@@ -0,0 +1,642 @@
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/secret_store.hpp"
#include "nocal/sync/token_broker.hpp"
#include <cstdint>
#include <exception>
#include <iostream>
#include <map>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
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<OAuthTokens> 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<std::string, OAuthTokens> store_data;
std::map<std::string, LoadResult> load_script;
std::map<std::string, StoreResult> store_script;
std::map<std::string, EraseResult> 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<OAuthTokens> 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<HttpRequest> requests;
std::vector<ResponseSpec> responses;
std::size_t fail_at{static_cast<std::size_t>(-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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<std::size_t>(-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<void>(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<void>(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;
}