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:
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);
|
||||
|
||||
Reference in New Issue
Block a user