feat(sync): add Microsoft account connect CLI
nocal account connect microsoft runs the PKCE browser flow, reads /me, upserts the deterministic account row, and persists tokens to Secret Service as the final connected-state gate. Account IDs are hashed from the remote subject so reconnect is idempotent, and a secret-store failure leaves the same coherent state as a disconnect. Ships a placeholder client ID with NOCAL_MICROSOFT_CLIENT_ID override until a real Entra registration exists.
This commit is contained in:
10
README.md
10
README.md
@@ -182,6 +182,10 @@ them; omission is not mislabeled as a remote deletion tombstone. This avoids
|
||||
beta and undocumented delta routes, at the cost of refetching each secondary
|
||||
window instead of incrementally updating it.
|
||||
|
||||
There is still no account UI/CLI orchestration or live Microsoft credential
|
||||
integration, and remote writes remain out of scope. Account setup and
|
||||
orchestration are next.
|
||||
Account setup begins with `nocal account connect microsoft`: it runs the
|
||||
PKCE browser flow, reads `/me`, upserts the deterministic account row, and
|
||||
stores the tokens in the Secret Service keyring as the final connected-state
|
||||
gate. This build ships a placeholder client ID; live connect requires a real
|
||||
mobile/desktop Entra registration supplied via `NOCAL_MICROSOFT_CLIENT_ID`.
|
||||
There is still no account TUI or sync orchestration, and remote writes remain
|
||||
out of scope.
|
||||
|
||||
@@ -288,6 +288,16 @@ This design avoids beta and undocumented per-calendar delta routes. Its
|
||||
tradeoff is bandwidth: every secondary window is fetched completely on each
|
||||
refresh, while the primary calendar remains incremental.
|
||||
|
||||
There is no account UI or CLI orchestration and no live Microsoft credential
|
||||
integration yet; tests use injected transports and credentials. Remote writes
|
||||
remain out of scope. Account setup and orchestration are next.
|
||||
Account setup now has a CLI entry point, `nocal account connect microsoft`.
|
||||
It composes the desktop OAuth adapters, libcurl transport, token parsing, one
|
||||
`/me` identity read, the sync cache, and the Secret Service store in a fixed
|
||||
order: the account row is upserted first, and the versioned secret payload is
|
||||
stored last as the connected-state gate. A secret-store failure therefore
|
||||
leaves the same coherent state as a disconnect, and reconnecting is idempotent
|
||||
because the local account id is a deterministic hash of the remote subject.
|
||||
This build carries a placeholder client ID; a real Entra registration is
|
||||
supplied through the `NOCAL_MICROSOFT_CLIENT_ID` environment variable (a
|
||||
public client ID, never a secret). There is no account TUI and no sync
|
||||
orchestration yet; tests use injected transports and credentials. Remote
|
||||
writes remain out of scope. Generic `SyncProvider` integration and observable
|
||||
sync status are next.
|
||||
|
||||
@@ -105,10 +105,23 @@ Completed provider-boundary contracts and token broker:
|
||||
`AccountDisconnected` for re-authentication
|
||||
- Credential material never appears in exception messages
|
||||
|
||||
Completed account setup (CLI):
|
||||
|
||||
- `nocal account connect microsoft` runs the PKCE browser flow, reads `/me`,
|
||||
upserts the deterministic account row, and persists tokens to Secret
|
||||
Service as the final connected-state gate
|
||||
- Deterministic local account IDs hashed from the remote subject make
|
||||
reconnect idempotent; a secret-store failure leaves the same coherent state
|
||||
as a disconnect
|
||||
- Placeholder client ID with `NOCAL_MICROSOFT_CLIENT_ID` override until a
|
||||
real Entra mobile/desktop registration exists
|
||||
- Deterministic failure-path tests: rejection, token errors, identity
|
||||
failure, storage failure, and credential redaction in error messages
|
||||
|
||||
Next provider-boundary slices:
|
||||
|
||||
- Account setup and CLI/TUI orchestration with live credential integration
|
||||
- Generic `SyncProvider` integration and observable sync status
|
||||
- TUI account orchestration and generic `SyncProvider` integration with
|
||||
observable sync status
|
||||
|
||||
## 0.3 — CalDAV
|
||||
|
||||
@@ -125,9 +138,10 @@ Next provider-boundary slices:
|
||||
|
||||
The Graph read boundary uses stable v1 delta for the primary calendar and
|
||||
complete stable-v1 calendar views for secondary calendars; it deliberately
|
||||
avoids beta and undocumented per-calendar delta routes. No account UI/CLI
|
||||
orchestration or live Microsoft credential integration exists yet, and remote
|
||||
writes remain out of scope.
|
||||
avoids beta and undocumented per-calendar delta routes. Account connect exists
|
||||
as a CLI command with live credential integration through Secret Service, but
|
||||
there is no account TUI or sync orchestration yet, and remote writes remain
|
||||
out of scope.
|
||||
|
||||
## 1.0 — distribution quality
|
||||
|
||||
|
||||
70
include/nocal/sync/microsoft_account.hpp
Normal file
70
include/nocal/sync/microsoft_account.hpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "nocal/storage/sync_cache.hpp"
|
||||
#include "nocal/sync/oauth.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
|
||||
class HttpTransport;
|
||||
class OAuthSecretStore;
|
||||
|
||||
// Placeholder used when no real Entra application registration is available.
|
||||
// It is not a registered client; interactive connect with it fails at the
|
||||
// authorization endpoint. Override with the NOCAL_MICROSOFT_CLIENT_ID
|
||||
// environment variable. A client ID is public configuration, never a secret.
|
||||
inline constexpr std::string_view microsoft_client_id_placeholder =
|
||||
"nocal-unregistered-client-id";
|
||||
|
||||
// Client ID used for interactive connect: the NOCAL_MICROSOFT_CLIENT_ID
|
||||
// environment variable when set and non-empty, otherwise the placeholder.
|
||||
[[nodiscard]] std::string default_microsoft_client_id();
|
||||
|
||||
// Public-desktop Microsoft OAuth configuration for the multitenant
|
||||
// organizational and personal Microsoft account audience with delegated
|
||||
// read-only scopes. The open-source binary never embeds a client secret.
|
||||
[[nodiscard]] OAuthClientConfig microsoft_oauth_config(std::string client_id);
|
||||
|
||||
class MicrosoftAccountError final : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// Deterministic local account id derived from the stable remote subject, so
|
||||
// reconnecting the same Microsoft account upserts the same cache row and
|
||||
// reuses the same Secret Service key.
|
||||
[[nodiscard]] std::string microsoft_account_id(std::string_view remote_subject);
|
||||
|
||||
// Runs one interactive Microsoft account connection:
|
||||
// 1. authorization-code flow with PKCE S256 (one browser launch, one
|
||||
// loopback callback, one token request) via authorize_with_pkce;
|
||||
// 2. strict token parsing that requires an initial refresh token;
|
||||
// 3. one /me identity read for the remote subject and display name;
|
||||
// 4. the account row upserted into the sync cache;
|
||||
// 5. the versioned token payload persisted to the Secret Service store.
|
||||
//
|
||||
// Invariants and failure semantics:
|
||||
// - The account is connected only after step 5 succeeds; there is no
|
||||
// plaintext fallback and no partial connected state.
|
||||
// - Failure before step 4 leaves the cache and the secret store untouched.
|
||||
// - Failure in step 5 leaves a cached account without credentials — the same
|
||||
// coherent state that disconnecting produces. Reconnecting is idempotent
|
||||
// because the account id is deterministic.
|
||||
// - The access token is never retained after return, and error messages
|
||||
// never contain credential material.
|
||||
[[nodiscard]] storage::CachedAccount connect_microsoft_account(
|
||||
const OAuthClientConfig& config,
|
||||
BrowserLauncher& browser,
|
||||
AuthorizationCallbackReceiver& receiver,
|
||||
HttpTransport& transport,
|
||||
EntropySource& entropy,
|
||||
OAuthSecretStore& secret_store,
|
||||
storage::SyncCache& cache,
|
||||
const std::function<std::int64_t()>& clock);
|
||||
|
||||
} // namespace nocal::sync
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
|
||||
@@ -28,6 +29,13 @@ public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// Validated /me identity. Fetched without persisting anything so account
|
||||
// connect can derive the deterministic local account id before any cache write.
|
||||
struct MicrosoftGraphIdentity {
|
||||
std::string remote_subject;
|
||||
std::string display_name;
|
||||
};
|
||||
|
||||
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
|
||||
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for
|
||||
// the primary calendar and complete calendarView reconciliation for secondary
|
||||
@@ -36,6 +44,10 @@ class MicrosoftGraphSync {
|
||||
public:
|
||||
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
|
||||
|
||||
// Requires delegated User.Read. Reads /me and returns the validated
|
||||
// identity without touching the cache; the access token is not retained.
|
||||
[[nodiscard]] MicrosoftGraphIdentity fetch_identity(std::string_view access_token) const;
|
||||
|
||||
// Requires delegated User.Read. A complete and validated /me response is
|
||||
// persisted atomically; failures leave the cached account unchanged.
|
||||
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);
|
||||
|
||||
@@ -28,6 +28,7 @@ nocal_sources = files(
|
||||
'src/storage/sync_cache.cpp',
|
||||
'src/sync/curl_http.cpp',
|
||||
'src/sync/desktop_oauth.cpp',
|
||||
'src/sync/microsoft_account.cpp',
|
||||
'src/sync/microsoft_graph.cpp',
|
||||
'src/sync/oauth.cpp',
|
||||
'src/sync/oauth_tokens.cpp',
|
||||
@@ -81,6 +82,9 @@ 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)
|
||||
microsoft_account_tests = executable('microsoft-account-tests',
|
||||
'tests/microsoft_account_tests.cpp', dependencies: nocal_dep)
|
||||
test('microsoft-account', microsoft_account_tests)
|
||||
secret_store_tests = executable('secret-store-tests',
|
||||
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
||||
test('secret-store', shell,
|
||||
|
||||
67
src/main.cpp
67
src/main.cpp
@@ -1,6 +1,11 @@
|
||||
#include "nocal/domain/calendar_transfer.hpp"
|
||||
#include "nocal/domain/date.hpp"
|
||||
#include "nocal/storage/ics_store.hpp"
|
||||
#include "nocal/storage/sync_cache.hpp"
|
||||
#include "nocal/sync/curl_http.hpp"
|
||||
#include "nocal/sync/desktop_oauth.hpp"
|
||||
#include "nocal/sync/microsoft_account.hpp"
|
||||
#include "nocal/sync/secret_store.hpp"
|
||||
#include "nocal/tui/Screen.hpp"
|
||||
#include "nocal/tui/TuiApp.hpp"
|
||||
|
||||
@@ -13,6 +18,7 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -45,6 +51,61 @@ std::filesystem::path default_calendar_path()
|
||||
return "calendar.ics";
|
||||
}
|
||||
|
||||
std::filesystem::path default_sync_cache_path()
|
||||
{
|
||||
if (const char* data_home = std::getenv("XDG_DATA_HOME");
|
||||
data_home != nullptr && *data_home != '\0') {
|
||||
return std::filesystem::path{data_home} / "nocal" / "sync.db";
|
||||
}
|
||||
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
|
||||
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "sync.db";
|
||||
}
|
||||
return "sync.db";
|
||||
}
|
||||
|
||||
int connect_microsoft_account()
|
||||
{
|
||||
const std::string client_id = nocal::sync::default_microsoft_client_id();
|
||||
if (client_id == nocal::sync::microsoft_client_id_placeholder) {
|
||||
std::cerr <<
|
||||
"nocal: this build has no registered Microsoft application client ID.\n"
|
||||
"Register a mobile and desktop public client with redirect URI\n"
|
||||
"http://localhost/nocal/oauth/callback, then set NOCAL_MICROSOFT_CLIENT_ID.\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::filesystem::path cache_path = default_sync_cache_path();
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(cache_path.parent_path(), error);
|
||||
if (error) {
|
||||
throw std::runtime_error("cannot create " + cache_path.parent_path().string() +
|
||||
": " + error.message());
|
||||
}
|
||||
nocal::storage::SyncCache cache(cache_path);
|
||||
nocal::sync::CurlHttpTransport transport;
|
||||
nocal::sync::XdgBrowserLauncher browser;
|
||||
nocal::sync::LoopbackCallbackReceiver receiver;
|
||||
nocal::sync::SystemEntropySource entropy;
|
||||
nocal::sync::LibsecretOAuthSecretStore secrets;
|
||||
const auto account = nocal::sync::connect_microsoft_account(
|
||||
nocal::sync::microsoft_oauth_config(client_id), browser, receiver, transport,
|
||||
entropy, secrets, cache, [] {
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
});
|
||||
std::cout << "Connected " << account.display_name << " (" << account.id << ")\n";
|
||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
|
||||
int account_command(int argc, char** argv)
|
||||
{
|
||||
if (argc == 3 && std::string_view{argv[1]} == "connect" &&
|
||||
std::string_view{argv[2]} == "microsoft") {
|
||||
return connect_microsoft_account();
|
||||
}
|
||||
throw std::invalid_argument("usage: nocal account connect microsoft");
|
||||
}
|
||||
|
||||
void print_help(std::ostream& output)
|
||||
{
|
||||
output <<
|
||||
@@ -60,6 +121,9 @@ void print_help(std::ostream& output)
|
||||
" --no-color disable ANSI styling\n"
|
||||
" -h, --help show this help\n"
|
||||
" -V, --version show the version\n\n"
|
||||
"Accounts: nocal account connect microsoft\n"
|
||||
" links a Microsoft account (browser sign-in, tokens stay in\n"
|
||||
" the Secret Service keyring) and exits.\n\n"
|
||||
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
||||
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||
@@ -313,6 +377,9 @@ int export_calendar(const std::filesystem::path& source_path,
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
try {
|
||||
if (argc >= 2 && std::string_view{argv[1]} == "account") {
|
||||
return account_command(argc - 1, argv + 1);
|
||||
}
|
||||
const auto options = parse_options(argc, argv);
|
||||
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
||||
print_warnings(loaded, options.calendar_path);
|
||||
|
||||
117
src/sync/microsoft_account.cpp
Normal file
117
src/sync/microsoft_account.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
#include "nocal/sync/microsoft_account.hpp"
|
||||
|
||||
#include "nocal/sync/microsoft_graph.hpp"
|
||||
#include "nocal/sync/oauth.hpp"
|
||||
#include "nocal/sync/oauth_tokens.hpp"
|
||||
#include "nocal/sync/secret_store.hpp"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||
std::array<unsigned char, 32> digest{};
|
||||
unsigned int size = 0;
|
||||
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|
||||
|| size != digest.size()) {
|
||||
throw std::runtime_error("cryptographic failure");
|
||||
}
|
||||
static constexpr char hexadecimal[] = "0123456789abcdef";
|
||||
std::string output;
|
||||
output.reserve(digest.size() * 2U);
|
||||
for (const unsigned char byte : digest) {
|
||||
output.push_back(hexadecimal[byte >> 4U]);
|
||||
output.push_back(hexadecimal[byte & 0x0fU]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string default_microsoft_client_id() {
|
||||
const char* value = std::getenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||
if (value != nullptr && value[0] != '\0') {
|
||||
return std::string(value);
|
||||
}
|
||||
return std::string(microsoft_client_id_placeholder);
|
||||
}
|
||||
|
||||
OAuthClientConfig microsoft_oauth_config(std::string client_id) {
|
||||
OAuthClientConfig config;
|
||||
config.client_id = std::move(client_id);
|
||||
config.authorization_endpoint =
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
||||
config.token_endpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
return config;
|
||||
}
|
||||
|
||||
std::string microsoft_account_id(std::string_view remote_subject) {
|
||||
std::string input("account");
|
||||
input.push_back('\0');
|
||||
input += "microsoft-graph";
|
||||
input.push_back('\0');
|
||||
input += remote_subject;
|
||||
return std::string("graph-account-") + sha256_hex(input);
|
||||
}
|
||||
|
||||
storage::CachedAccount connect_microsoft_account(const OAuthClientConfig& config,
|
||||
BrowserLauncher& browser, AuthorizationCallbackReceiver& receiver,
|
||||
HttpTransport& transport, EntropySource& entropy, OAuthSecretStore& secret_store,
|
||||
storage::SyncCache& cache, const std::function<std::int64_t()>& clock) {
|
||||
// Step 1: authorization-code flow with PKCE S256
|
||||
HttpResponse response;
|
||||
try {
|
||||
response = authorize_with_pkce(config, browser, receiver, transport, entropy);
|
||||
} catch (const std::exception&) {
|
||||
throw MicrosoftAccountError("Microsoft authorization failed");
|
||||
}
|
||||
|
||||
// Step 2: strict token parsing (requires initial refresh token)
|
||||
OAuthTokens tokens;
|
||||
try {
|
||||
tokens = parse_oauth_token_response(response.body, clock());
|
||||
} catch (const std::exception&) {
|
||||
throw MicrosoftAccountError("Microsoft token response invalid");
|
||||
}
|
||||
|
||||
// Step 3: one /me identity read
|
||||
MicrosoftGraphSync graph{transport, cache};
|
||||
MicrosoftGraphIdentity identity;
|
||||
try {
|
||||
identity = graph.fetch_identity(tokens.access_token);
|
||||
} catch (const std::exception&) {
|
||||
throw MicrosoftAccountError("Microsoft identity read failed");
|
||||
}
|
||||
|
||||
// Step 4: upsert account into the sync cache
|
||||
const std::string account_id = microsoft_account_id(identity.remote_subject);
|
||||
const storage::CachedAccount account{
|
||||
account_id, "microsoft-graph", identity.remote_subject, identity.display_name};
|
||||
try {
|
||||
cache.upsert_account(account);
|
||||
} catch (const std::exception&) {
|
||||
throw MicrosoftAccountError("sync cache update failed");
|
||||
}
|
||||
|
||||
// Step 5: persist credentials to the secret store
|
||||
// On failure the cached account row remains (documented disconnected state).
|
||||
try {
|
||||
secret_store.store(account_id, tokens);
|
||||
} catch (const std::exception&) {
|
||||
throw MicrosoftAccountError("credential storage failed");
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
} // namespace nocal::sync
|
||||
@@ -533,19 +533,32 @@ struct DeltaContinuation {
|
||||
MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
|
||||
: transport_(transport), cache_(cache) {}
|
||||
|
||||
MicrosoftGraphIdentity MicrosoftGraphSync::fetch_identity(
|
||||
std::string_view access_token) const {
|
||||
try {
|
||||
const MicrosoftGraphCredentials credentials{"identity", std::string(access_token)};
|
||||
validate_credentials(credentials);
|
||||
const nlohmann::json object = parse_json_object(
|
||||
send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
|
||||
MicrosoftGraphIdentity identity{required_string(object, "id"),
|
||||
optional_string(object, "displayName")};
|
||||
if (identity.display_name.empty()) {
|
||||
identity.display_name = required_string(object, "userPrincipalName");
|
||||
}
|
||||
return identity;
|
||||
} catch (...) {
|
||||
throw MicrosoftGraphError("Microsoft Graph identity read failed");
|
||||
}
|
||||
}
|
||||
|
||||
CachedAccount MicrosoftGraphSync::refresh_account(
|
||||
const MicrosoftGraphCredentials& credentials) {
|
||||
try {
|
||||
validate_credentials(credentials);
|
||||
const nlohmann::json object =
|
||||
parse_json_object(send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
|
||||
const std::string remote_subject = required_string(object, "id");
|
||||
std::string display_name = optional_string(object, "displayName");
|
||||
if (display_name.empty()) {
|
||||
display_name = required_string(object, "userPrincipalName");
|
||||
}
|
||||
const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token);
|
||||
CachedAccount account{
|
||||
credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)};
|
||||
credentials.account_id, "microsoft-graph", identity.remote_subject,
|
||||
identity.display_name};
|
||||
cache_.upsert_account(account);
|
||||
return account;
|
||||
} catch (...) {
|
||||
|
||||
886
tests/microsoft_account_tests.cpp
Normal file
886
tests/microsoft_account_tests.cpp
Normal file
@@ -0,0 +1,886 @@
|
||||
#include "nocal/storage/sync_cache.hpp"
|
||||
#include "nocal/sync/microsoft_account.hpp"
|
||||
#include "nocal/sync/microsoft_graph.hpp"
|
||||
#include "nocal/sync/oauth.hpp"
|
||||
#include "nocal/sync/oauth_tokens.hpp"
|
||||
#include "nocal/sync/secret_store.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using nocal::storage::CachedAccount;
|
||||
using nocal::storage::SyncCache;
|
||||
using nocal::sync::AuthorizationCallback;
|
||||
using nocal::sync::AuthorizationCallbackReceiver;
|
||||
using nocal::sync::BrowserLauncher;
|
||||
using nocal::sync::EntropySource;
|
||||
using nocal::sync::HttpMethod;
|
||||
using nocal::sync::HttpRequest;
|
||||
using nocal::sync::HttpResponse;
|
||||
using nocal::sync::HttpTransport;
|
||||
using nocal::sync::MicrosoftAccountError;
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TempDirectory for a real SyncCache
|
||||
// -----------------------------------------------------------------------
|
||||
class TempDirectory {
|
||||
public:
|
||||
TempDirectory() {
|
||||
std::string pattern =
|
||||
(std::filesystem::temp_directory_path() / "nocal-microsoft-account-tests.XXXXXX")
|
||||
.string();
|
||||
std::vector<char> writable(pattern.begin(), pattern.end());
|
||||
writable.push_back('\0');
|
||||
const char* created = ::mkdtemp(writable.data());
|
||||
if (created == nullptr) {
|
||||
throw std::runtime_error("mkdtemp failed");
|
||||
}
|
||||
path_ = created;
|
||||
}
|
||||
|
||||
TempDirectory(const TempDirectory&) = delete;
|
||||
TempDirectory& operator=(const TempDirectory&) = delete;
|
||||
|
||||
~TempDirectory() {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove_all(path_, ignored);
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
|
||||
|
||||
private:
|
||||
std::filesystem::path path_;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ScriptedEntropy – fixed deterministic values
|
||||
// -----------------------------------------------------------------------
|
||||
class ScriptedEntropy final : public EntropySource {
|
||||
public:
|
||||
explicit ScriptedEntropy(std::vector<std::array<std::byte, 32>> scripted_values)
|
||||
: values(std::move(scripted_values)) {}
|
||||
|
||||
std::vector<std::array<std::byte, 32>> values;
|
||||
std::size_t calls{0};
|
||||
|
||||
void fill(std::span<std::byte> output) override {
|
||||
require(output.size() == 32, "entropy request was not exactly 32 bytes");
|
||||
const std::size_t call = calls++;
|
||||
require(call < values.size(), "unexpected entropy request");
|
||||
std::copy(values[call].begin(), values[call].end(), output.begin());
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ScriptedBrowser
|
||||
// -----------------------------------------------------------------------
|
||||
class ScriptedBrowser final : public BrowserLauncher {
|
||||
public:
|
||||
std::vector<std::string> urls;
|
||||
|
||||
void open(const std::string& url) override { urls.push_back(url); }
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ScriptedReceiver – copies state from browser URL if needed
|
||||
// -----------------------------------------------------------------------
|
||||
class ScriptedReceiver final : public AuthorizationCallbackReceiver {
|
||||
public:
|
||||
std::string redirect{"http://127.0.0.1:43123/callback"};
|
||||
AuthorizationCallback callback;
|
||||
std::size_t receives{0};
|
||||
bool copy_browser_state{true};
|
||||
ScriptedBrowser* browser{nullptr};
|
||||
|
||||
[[nodiscard]] std::string redirect_uri() const override { return redirect; }
|
||||
|
||||
AuthorizationCallback receive() override {
|
||||
++receives;
|
||||
if (copy_browser_state && browser != nullptr && !browser->urls.empty()) {
|
||||
// Extract state from the authorization URL
|
||||
const std::string& url = browser->urls.front();
|
||||
const std::size_t state_pos = url.find("state=");
|
||||
if (state_pos != std::string::npos) {
|
||||
const std::size_t value_start = state_pos + 6;
|
||||
const std::size_t amp = url.find('&', value_start);
|
||||
const std::size_t len = (amp == std::string::npos) ? url.size() - value_start
|
||||
: amp - value_start;
|
||||
AuthorizationCallback result = callback;
|
||||
result.state = url.substr(value_start, len);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return callback;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ScriptedTransport – scripted responses for token exchange then /me
|
||||
// -----------------------------------------------------------------------
|
||||
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;
|
||||
|
||||
HttpResponse send(const HttpRequest& request) override {
|
||||
requests.push_back(request);
|
||||
const std::size_t index = requests.size() - 1;
|
||||
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};
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ScriptedSecretStore – in-memory with injectable store failure
|
||||
// -----------------------------------------------------------------------
|
||||
class ScriptedSecretStore final : public OAuthSecretStore {
|
||||
public:
|
||||
std::map<std::string, OAuthTokens> store_data;
|
||||
bool throw_on_store{false};
|
||||
std::string store_error_message{};
|
||||
std::size_t store_calls{0};
|
||||
std::size_t load_calls{0};
|
||||
std::size_t erase_calls{0};
|
||||
|
||||
void store(std::string account_id, const OAuthTokens& tokens, std::stop_token = {}) override {
|
||||
++store_calls;
|
||||
if (throw_on_store) {
|
||||
throw std::runtime_error(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 = store_data.find(account_id);
|
||||
if (found != store_data.end()) {
|
||||
return found->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void erase(std::string account_id, std::stop_token = {}) override {
|
||||
++erase_calls;
|
||||
store_data.erase(account_id);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Controllable clock
|
||||
// -----------------------------------------------------------------------
|
||||
class ControllableClock {
|
||||
public:
|
||||
std::int64_t value{1000};
|
||||
|
||||
[[nodiscard]] std::int64_t operator()() { return value; }
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[[nodiscard]] std::array<std::byte, 32> zeros() { return {}; }
|
||||
|
||||
[[nodiscard]] std::string token_response_body(std::string_view access_token,
|
||||
std::string_view refresh_token, std::int64_t expires_in) {
|
||||
return "{\"access_token\":\"" + std::string(access_token)
|
||||
+ "\",\"refresh_token\":\"" + std::string(refresh_token)
|
||||
+ "\",\"token_type\":\"Bearer\",\"expires_in\":" + std::to_string(expires_in) + "}";
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string me_response_body(
|
||||
std::string_view id, std::string_view display_name, std::string_view user_principal_name) {
|
||||
std::string body = "{\"id\":\"" + std::string(id) + "\"";
|
||||
if (!display_name.empty()) {
|
||||
body += ",\"displayName\":\"" + std::string(display_name) + "\"";
|
||||
}
|
||||
if (!user_principal_name.empty()) {
|
||||
body += ",\"userPrincipalName\":\"" + std::string(user_principal_name) + "\"";
|
||||
}
|
||||
body += "}";
|
||||
return body;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Happy path
|
||||
// -----------------------------------------------------------------------
|
||||
void test_happy_path() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, token_response_body("access-token-value", "refresh-token-value", 3600)});
|
||||
transport.responses.push_back(
|
||||
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
const CachedAccount account = nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
|
||||
// Verify returned account
|
||||
require(account.provider == "microsoft-graph", "wrong provider");
|
||||
|
||||
// Compute expected account id
|
||||
const std::string expected_id =
|
||||
nocal::sync::microsoft_account_id("remote-subject");
|
||||
require(account.id == expected_id, "account id mismatch");
|
||||
require(account.remote_subject == "remote-subject", "remote subject mismatch");
|
||||
require(account.display_name == "Display Name", "display name mismatch");
|
||||
|
||||
// Cache snapshot has exactly one account
|
||||
const auto snapshot = cache.snapshot();
|
||||
require(snapshot.accounts.size() == 1, "expected exactly one cached account");
|
||||
require(snapshot.accounts.front() == account, "cached account differs from returned");
|
||||
|
||||
// Secret store holds the parsed tokens under the same id
|
||||
const auto stored_tokens = secret_store.store_data.find(expected_id);
|
||||
require(stored_tokens != secret_store.store_data.end(), "tokens not stored");
|
||||
require(stored_tokens->second.access_token == "access-token-value",
|
||||
"wrong stored access token");
|
||||
require(stored_tokens->second.refresh_token == "refresh-token-value",
|
||||
"wrong stored refresh token");
|
||||
require(stored_tokens->second.token_type == "Bearer", "wrong stored token type");
|
||||
|
||||
// Exactly one browser URL, one token request, one /me GET with Bearer token
|
||||
require(browser.urls.size() == 1, "expected exactly one browser URL");
|
||||
require(transport.requests.size() == 2, "expected exactly two HTTP requests");
|
||||
require(transport.requests[0].method == HttpMethod::post,
|
||||
"first request was not POST (token)");
|
||||
require(transport.requests[0].url == "https://login.example/token",
|
||||
"first request URL mismatch");
|
||||
require(transport.requests[1].method == HttpMethod::get,
|
||||
"second request was not GET (/me)");
|
||||
require(transport.requests[1].url.find("graph.microsoft.com") != std::string::npos,
|
||||
"second request URL does not contain graph.microsoft.com");
|
||||
bool has_bearer = false;
|
||||
for (const auto& header : transport.requests[1].headers) {
|
||||
if (header.name == "Authorization") {
|
||||
has_bearer = header.value.find("Bearer access-token-value") != std::string::npos;
|
||||
}
|
||||
}
|
||||
require(has_bearer, "/me request did not carry Bearer token");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Idempotent reconnect
|
||||
// -----------------------------------------------------------------------
|
||||
void test_idempotent_reconnect() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
auto do_connect = [&]() -> std::string {
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, token_response_body("access-token-" + std::to_string(transport.requests.size()),
|
||||
"refresh-token-" + std::to_string(transport.requests.size()), 3600)});
|
||||
transport.responses.push_back(
|
||||
{200, me_response_body("same-subject", "Same User", "same@example.com")});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
const CachedAccount account = nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
return account.id;
|
||||
};
|
||||
|
||||
const std::string first_id = do_connect();
|
||||
const std::string second_id = do_connect();
|
||||
|
||||
// Same account id
|
||||
require(first_id == second_id, "reconnect produced different account id");
|
||||
|
||||
// Cache still has exactly one account
|
||||
const auto snapshot = cache.snapshot();
|
||||
require(snapshot.accounts.size() == 1, "expected exactly one account after reconnect");
|
||||
require(snapshot.accounts.front().id == first_id, "account id mismatch in cache");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Authorization rejected
|
||||
// -----------------------------------------------------------------------
|
||||
void test_authorization_rejected() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"", "", "access_denied", ""};
|
||||
ScriptedTransport transport;
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const MicrosoftAccountError& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()) == "Microsoft authorization failed",
|
||||
"wrong error message on authorization rejection");
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(
|
||||
std::string("wrong exception type: ") + e.what());
|
||||
}
|
||||
require(caught, "expected MicrosoftAccountError");
|
||||
|
||||
// Cache and secret store both untouched
|
||||
require(cache.snapshot().accounts.empty(), "cache was modified on authorization rejection");
|
||||
require(secret_store.store_calls == 0, "secret store was called on authorization rejection");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Token endpoint returns non-2xx
|
||||
// -----------------------------------------------------------------------
|
||||
void test_token_endpoint_non2xx() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back({400, "{\"error\":\"invalid_grant\"}"});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const MicrosoftAccountError& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()) == "Microsoft authorization failed",
|
||||
"wrong error message on token rejection");
|
||||
}
|
||||
require(caught, "expected MicrosoftAccountError");
|
||||
|
||||
// Nothing stored
|
||||
require(cache.snapshot().accounts.empty(), "cache was modified on token error");
|
||||
require(secret_store.store_calls == 0, "secret store was called on token error");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Token response missing refresh_token
|
||||
// -----------------------------------------------------------------------
|
||||
void test_token_response_missing_refresh() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
// No refresh_token in response — parse_oauth_token_response will reject
|
||||
transport.responses.push_back(
|
||||
{200, "{\"access_token\":\"acc\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const MicrosoftAccountError& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()) == "Microsoft token response invalid",
|
||||
"wrong error message on missing refresh token");
|
||||
}
|
||||
require(caught, "expected MicrosoftAccountError");
|
||||
|
||||
// Nothing stored
|
||||
require(cache.snapshot().accounts.empty(), "cache was modified on parse error");
|
||||
require(secret_store.store_calls == 0, "secret store was called on parse error");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: /me failure
|
||||
// -----------------------------------------------------------------------
|
||||
void test_me_failure() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
// Successful token response
|
||||
transport.responses.push_back(
|
||||
{200, token_response_body("acc-token", "ref-token", 3600)});
|
||||
// /me returns garbage (malformed JSON)
|
||||
transport.responses.push_back({200, "not-valid-json"});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const MicrosoftAccountError& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()) == "Microsoft identity read failed",
|
||||
"wrong error message on /me failure");
|
||||
}
|
||||
require(caught, "expected MicrosoftAccountError");
|
||||
|
||||
// Cache and secret store untouched
|
||||
require(cache.snapshot().accounts.empty(), "cache was modified on /me failure");
|
||||
require(secret_store.store_calls == 0, "secret store was called on /me failure");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Secret-store store() failure — cache retains the account row
|
||||
// -----------------------------------------------------------------------
|
||||
void test_secret_store_failure() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, token_response_body("acc-token", "ref-token", 3600)});
|
||||
transport.responses.push_back(
|
||||
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
|
||||
ScriptedSecretStore secret_store;
|
||||
secret_store.throw_on_store = true;
|
||||
secret_store.store_error_message = "secret service unavailable";
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const MicrosoftAccountError& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()) == "credential storage failed",
|
||||
"wrong error message on store failure");
|
||||
}
|
||||
require(caught, "expected MicrosoftAccountError");
|
||||
|
||||
// Cache DOES retain the account row (documented disconnected state)
|
||||
const auto snapshot = cache.snapshot();
|
||||
require(snapshot.accounts.size() == 1,
|
||||
"cache should retain the account row even when secret store fails");
|
||||
require(snapshot.accounts.front().remote_subject == "remote-subject",
|
||||
"cached account has wrong remote subject");
|
||||
|
||||
// Verify secret store does NOT have the credentials
|
||||
const std::string expected_id =
|
||||
nocal::sync::microsoft_account_id("remote-subject");
|
||||
const auto stored = secret_store.store_data.find(expected_id);
|
||||
require(stored == secret_store.store_data.end(),
|
||||
"secret store should not contain credentials after store failure");
|
||||
require(secret_store.store_calls == 1, "store should have been attempted once");
|
||||
|
||||
// A subsequent successful connect should complete (idempotent)
|
||||
ScriptedEntropy entropy2{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser2;
|
||||
ScriptedReceiver receiver2;
|
||||
receiver2.browser = &browser2;
|
||||
receiver2.callback = {"auth-code-2", "", "", ""};
|
||||
ScriptedTransport transport2;
|
||||
transport2.responses.push_back(
|
||||
{200, token_response_body("acc-token-2", "ref-token-2", 3600)});
|
||||
transport2.responses.push_back(
|
||||
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
|
||||
ScriptedSecretStore secret_store2;
|
||||
ControllableClock clock2;
|
||||
|
||||
const CachedAccount retry_account = nocal::sync::connect_microsoft_account(
|
||||
config, browser2, receiver2, transport2, entropy2, secret_store2, cache,
|
||||
[&clock2]() { return clock2(); });
|
||||
require(retry_account.id == expected_id,
|
||||
"retry account id should match deterministic id");
|
||||
require(cache.snapshot().accounts.size() == 1,
|
||||
"cache should still have exactly one account after successful retry");
|
||||
|
||||
const auto stored2 = secret_store2.store_data.find(expected_id);
|
||||
require(stored2 != secret_store2.store_data.end(),
|
||||
"credentials should be stored after successful retry");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Empty displayName falls back to userPrincipalName
|
||||
// -----------------------------------------------------------------------
|
||||
void test_empty_display_name_fallback() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {"auth-code", "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, token_response_body("acc-token", "ref-token", 3600)});
|
||||
// No displayName in /me response
|
||||
transport.responses.push_back(
|
||||
{200, me_response_body("remote-subject", "", "fallback@example.com")});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
const CachedAccount account = nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
|
||||
require(account.display_name == "fallback@example.com",
|
||||
"display name should fall back to userPrincipalName");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: default_microsoft_client_id with env unset and set
|
||||
// -----------------------------------------------------------------------
|
||||
void test_default_client_id() {
|
||||
// Save original env state and unset
|
||||
const char* original = std::getenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||
|
||||
// Test with env unset
|
||||
if (original != nullptr) {
|
||||
::unsetenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||
}
|
||||
const std::string unset_result = nocal::sync::default_microsoft_client_id();
|
||||
require(unset_result == nocal::sync::microsoft_client_id_placeholder,
|
||||
"default client id should be placeholder when env is unset");
|
||||
|
||||
// Test with env set
|
||||
::setenv("NOCAL_MICROSOFT_CLIENT_ID", "my-test-client-id", 1);
|
||||
const std::string set_result = nocal::sync::default_microsoft_client_id();
|
||||
require(set_result == "my-test-client-id",
|
||||
"default client id should match env when set");
|
||||
|
||||
// Restore original
|
||||
if (original != nullptr) {
|
||||
::setenv("NOCAL_MICROSOFT_CLIENT_ID", original, 1);
|
||||
} else {
|
||||
::unsetenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: microsoft_oauth_config
|
||||
// -----------------------------------------------------------------------
|
||||
void test_microsoft_oauth_config() {
|
||||
const OAuthClientConfig config = nocal::sync::microsoft_oauth_config("my-client-id");
|
||||
|
||||
require(config.client_id == "my-client-id", "client_id mismatch");
|
||||
require(config.authorization_endpoint
|
||||
== "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
"wrong authorization endpoint");
|
||||
require(config.token_endpoint
|
||||
== "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
"wrong token endpoint");
|
||||
|
||||
require(config.scopes.size() == 3, "expected exactly 3 scopes");
|
||||
require(config.scopes[0] == "offline_access", "scope[0] mismatch");
|
||||
require(config.scopes[1] == "User.Read", "scope[1] mismatch");
|
||||
require(config.scopes[2] == "Calendars.Read", "scope[2] mismatch");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Error messages never contain credential material
|
||||
// -----------------------------------------------------------------------
|
||||
void test_error_message_redaction() {
|
||||
TempDirectory temp;
|
||||
SyncCache cache(temp.path() / "cache.db");
|
||||
|
||||
const std::string access_marker = "ACCESS_CRED_MARKER_12345";
|
||||
const std::string refresh_marker = "REFRESH_CRED_MARKER_67890";
|
||||
const std::string auth_code_marker = "AUTH_CODE_MARKER_ABCDE";
|
||||
|
||||
// Test 1: Token endpoint error (400)
|
||||
{
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {auth_code_marker, "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back({400, access_marker + refresh_marker});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const std::exception& e) {
|
||||
const std::string msg = e.what();
|
||||
require(msg.find(access_marker) == std::string::npos,
|
||||
"error leaked access token on 400");
|
||||
require(msg.find(refresh_marker) == std::string::npos,
|
||||
"error leaked refresh token on 400");
|
||||
require(msg.find(auth_code_marker) == std::string::npos,
|
||||
"error leaked auth code on 400");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Parse error (missing refresh_token)
|
||||
{
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {auth_code_marker, "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, "{\"access_token\":\"" + access_marker
|
||||
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const std::exception& e) {
|
||||
const std::string msg = e.what();
|
||||
require(msg.find(access_marker) == std::string::npos,
|
||||
"error leaked access token on parse failure");
|
||||
require(msg.find(auth_code_marker) == std::string::npos,
|
||||
"error leaked auth code on parse failure");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: /me failure
|
||||
{
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {auth_code_marker, "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, "{\"access_token\":\"" + access_marker
|
||||
+ "\",\"refresh_token\":\"" + refresh_marker
|
||||
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||
transport.responses.push_back(
|
||||
{200, "{\"bad_field\":" + access_marker + "}"});
|
||||
ScriptedSecretStore secret_store;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const std::exception& e) {
|
||||
const std::string msg = e.what();
|
||||
require(msg.find(access_marker) == std::string::npos,
|
||||
"error leaked access token on /me failure");
|
||||
require(msg.find(refresh_marker) == std::string::npos,
|
||||
"error leaked refresh token on /me failure");
|
||||
require(msg.find(auth_code_marker) == std::string::npos,
|
||||
"error leaked auth code on /me failure");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Secret store failure
|
||||
{
|
||||
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||
ScriptedBrowser browser;
|
||||
ScriptedReceiver receiver;
|
||||
receiver.browser = &browser;
|
||||
receiver.callback = {auth_code_marker, "", "", ""};
|
||||
ScriptedTransport transport;
|
||||
transport.responses.push_back(
|
||||
{200, "{\"access_token\":\"" + access_marker
|
||||
+ "\",\"refresh_token\":\"" + refresh_marker
|
||||
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||
transport.responses.push_back(
|
||||
{200, me_response_body("remote-subject", "Display", "user@example.com")});
|
||||
ScriptedSecretStore secret_store;
|
||||
secret_store.throw_on_store = true;
|
||||
ControllableClock clock;
|
||||
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "test-client-id";
|
||||
config.authorization_endpoint = "https://login.example/auth";
|
||||
config.token_endpoint = "https://login.example/token";
|
||||
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||
|
||||
try {
|
||||
(void)nocal::sync::connect_microsoft_account(
|
||||
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||
[&clock]() { return clock(); });
|
||||
} catch (const std::exception& e) {
|
||||
const std::string msg = e.what();
|
||||
require(msg.find(access_marker) == std::string::npos,
|
||||
"error leaked access token on store failure");
|
||||
require(msg.find(refresh_marker) == std::string::npos,
|
||||
"error leaked refresh token on store failure");
|
||||
require(msg.find(auth_code_marker) == std::string::npos,
|
||||
"error leaked auth code on store failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: microsoft_account_id is deterministic
|
||||
// -----------------------------------------------------------------------
|
||||
void test_microsoft_account_id_deterministic() {
|
||||
const std::string first = nocal::sync::microsoft_account_id("user@example.com");
|
||||
const std::string second = nocal::sync::microsoft_account_id("user@example.com");
|
||||
require(
|
||||
first == second, "microsoft_account_id is not deterministic for same input");
|
||||
|
||||
const std::string different =
|
||||
nocal::sync::microsoft_account_id("other@example.com");
|
||||
require(first != different,
|
||||
"microsoft_account_id should differ for different remote subjects");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_happy_path();
|
||||
test_idempotent_reconnect();
|
||||
test_authorization_rejected();
|
||||
test_token_endpoint_non2xx();
|
||||
test_token_response_missing_refresh();
|
||||
test_me_failure();
|
||||
test_secret_store_failure();
|
||||
test_empty_display_name_fallback();
|
||||
test_default_client_id();
|
||||
test_microsoft_oauth_config();
|
||||
test_error_message_redaction();
|
||||
test_microsoft_account_id_deterministic();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "microsoft account tests failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "microsoft account tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user