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:
@@ -93,6 +93,18 @@ Completed secondary-calendar stable-v1 reconciliation:
|
|||||||
- Overlapping windows retain events until their last membership is removed
|
- Overlapping windows retain events until their last membership is removed
|
||||||
- No beta or undocumented per-calendar delta dependency
|
- 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:
|
Next provider-boundary slices:
|
||||||
|
|
||||||
- Account setup and CLI/TUI orchestration with live credential integration
|
- Account setup and CLI/TUI orchestration with live credential integration
|
||||||
|
|||||||
56
include/nocal/sync/provider.hpp
Normal file
56
include/nocal/sync/provider.hpp
Normal 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
|
||||||
58
include/nocal/sync/token_broker.hpp
Normal file
58
include/nocal/sync/token_broker.hpp
Normal 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
|
||||||
@@ -32,6 +32,7 @@ nocal_sources = files(
|
|||||||
'src/sync/oauth.cpp',
|
'src/sync/oauth.cpp',
|
||||||
'src/sync/oauth_tokens.cpp',
|
'src/sync/oauth_tokens.cpp',
|
||||||
'src/sync/secret_store.cpp',
|
'src/sync/secret_store.cpp',
|
||||||
|
'src/sync/token_broker.cpp',
|
||||||
'src/tui/Screen.cpp',
|
'src/tui/Screen.cpp',
|
||||||
'src/tui/EventEditor.cpp',
|
'src/tui/EventEditor.cpp',
|
||||||
'src/tui/Terminal.cpp',
|
'src/tui/Terminal.cpp',
|
||||||
@@ -72,6 +73,9 @@ test('desktop-oauth', desktop_oauth_tests)
|
|||||||
oauth_token_tests = executable('oauth-token-tests',
|
oauth_token_tests = executable('oauth-token-tests',
|
||||||
'tests/oauth_token_tests.cpp', dependencies: nocal_dep)
|
'tests/oauth_token_tests.cpp', dependencies: nocal_dep)
|
||||||
test('oauth-tokens', oauth_token_tests)
|
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',
|
secret_store_tests = executable('secret-store-tests',
|
||||||
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
||||||
test('secret-store', shell,
|
test('secret-store', shell,
|
||||||
|
|||||||
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
|
||||||
642
tests/token_broker_tests.cpp
Normal file
642
tests/token_broker_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user