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:
147
src/sync/token_broker.cpp
Normal file
147
src/sync/token_broker.cpp
Normal 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
|
||||
Reference in New Issue
Block a user