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

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