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:
2026-07-21 18:44:47 +01:00
parent feec691535
commit 74c798e7aa
10 changed files with 1216 additions and 19 deletions

View 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

View File

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