From d2573d356132ac4ba9c12811a85a77e4b1397c3e Mon Sep 17 00:00:00 2001 From: Suyash Kshirsagar Date: Wed, 22 Apr 2026 22:00:28 -0700 Subject: [PATCH] fix: per-tenant AuthenticationRecord + InteractiveBrowserCredential in auth.py Two related auth.py fixes for the developer-box flow, combined because they touch overlapping code paths. 1. Per-tenant AuthenticationRecord (cross-tenant bug fix) Previously auth.py stored the AuthenticationRecord at a single global path (dataverse_cli_auth_record.json). Because a record's home_account_id is tenant-bound, switching tenants overwrote the file and forced a full re-auth on the next switch-back. Now the record path includes TENANT_ID: dataverse_cli_auth_record_.json Existing users are handled gracefully: _read_auth_record() falls back to the legacy global path on first run; the next successful auth writes to the new per-tenant path. Same-tenant multi-env behavior is unchanged (MSAL's internal cache already holds multiple resource-scope access tokens). 2. InteractiveBrowserCredential instead of DeviceCodeCredential The dv-connect flow already uses interactive browser auth for PAC CLI and the MCP proxy. auth.py was the outlier: it printed a URL and asked the user to type a code. That was the only manual-typing step in the whole flow. Switching to InteractiveBrowserCredential aligns auth.py with the rest of the stack, matches industry convention (Azure CLI, az login, gh auth login), and eliminates the code-typing UX. Token caching unchanged: same TokenCachePersistenceOptions, same AuthenticationRecord persistence (now per-tenant). No new env vars. Version: 1.2.0 -> 1.2.1 (PATCH) Co-Authored-By: Claude Haiku 4.5 --- .github/plugin/marketplace.json | 4 +- .../dataverse/.claude-plugin/plugin.json | 2 +- .../dataverse/.github/plugin/plugin.json | 2 +- .github/plugins/dataverse/scripts/auth.py | 108 ++++++++++++------ 4 files changed, 77 insertions(+), 39 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index f13cc28..8151cc5 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -2,7 +2,7 @@ "name": "dataverse-skills", "metadata": { "description": "Official Dataverse plugin marketplace for agent-guided development", - "version": "1.2.0" + "version": "1.2.1" }, "owner": { "name": "Microsoft", @@ -13,7 +13,7 @@ "name": "dataverse", "description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.", "source": "./.github/plugins/dataverse", - "version": "1.2.0", + "version": "1.2.1", "homepage": "https://github.com/microsoft/Dataverse-skills" } ] diff --git a/.github/plugins/dataverse/.claude-plugin/plugin.json b/.github/plugins/dataverse/.claude-plugin/plugin.json index 56d4fbf..f24399c 100644 --- a/.github/plugins/dataverse/.claude-plugin/plugin.json +++ b/.github/plugins/dataverse/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dataverse", "description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.", - "version": "1.2.0", + "version": "1.2.1", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" diff --git a/.github/plugins/dataverse/.github/plugin/plugin.json b/.github/plugins/dataverse/.github/plugin/plugin.json index 826f144..e3593d3 100644 --- a/.github/plugins/dataverse/.github/plugin/plugin.json +++ b/.github/plugins/dataverse/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dataverse", "description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.", - "version": "1.2.0", + "version": "1.2.1", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" diff --git a/.github/plugins/dataverse/scripts/auth.py b/.github/plugins/dataverse/scripts/auth.py index b844f64..585608a 100644 --- a/.github/plugins/dataverse/scripts/auth.py +++ b/.github/plugins/dataverse/scripts/auth.py @@ -3,14 +3,19 @@ Auth priority: 1. Service principal (CLIENT_ID + CLIENT_SECRET in .env) — non-interactive - 2. Device code flow — interactive on first login, silent refresh thereafter + 2. Interactive browser flow — opens a browser window once, silent refresh + thereafter. Matches the auth UX used by PAC CLI and the Dataverse MCP proxy, + so users see one browser sign-in instead of a device code they must type. Token caching: - Service principal: in-memory (tokens are short-lived, no persistent cache needed) - - Device code: OS credential store (Windows Credential Manager, macOS Keychain, - Linux libsecret) via TokenCachePersistenceOptions. An AuthenticationRecord is - persisted alongside the token cache so that new processes can silently refresh - without re-prompting the user. + - Interactive browser: OS credential store (Windows Credential Manager, macOS + Keychain, Linux libsecret) via TokenCachePersistenceOptions. An + AuthenticationRecord is persisted alongside the token cache so that new + processes can silently refresh without re-prompting the user. The record is + stored **per tenant** — switching tenants does not invalidate other tenants' + records. A legacy global record from older versions of auth.py is read on + first run and migrated forward via the next successful auth. Functions: load_env() — loads .env into os.environ @@ -39,8 +44,41 @@ import sys from pathlib import Path -# AuthenticationRecord is persisted here so new processes skip device code flow -_AUTH_RECORD_PATH = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".IdentityService" / "dataverse_cli_auth_record.json" +# AuthenticationRecord is persisted here so new processes skip device code flow. +# Keyed by TENANT_ID because a record's home_account_id is tenant-bound; a single +# global file gets overwritten on tenant switch and forces re-auth. See +# _auth_record_path() for the resolved path and legacy fallback. +_AUTH_RECORD_DIR = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".IdentityService" +_AUTH_RECORD_LEGACY_PATH = _AUTH_RECORD_DIR / "dataverse_cli_auth_record.json" + + +def _auth_record_path(tenant_id): + """Return the per-tenant AuthenticationRecord file path. + + If tenant_id is falsy, falls back to the legacy global path so we still + work when TENANT_ID is missing from .env. + """ + if not tenant_id: + return _AUTH_RECORD_LEGACY_PATH + return _AUTH_RECORD_DIR / f"dataverse_cli_auth_record_{tenant_id}.json" + + +def _read_auth_record(tenant_id): + """Deserialize the AuthenticationRecord for this tenant. + + Tries the per-tenant path first, then falls back to the legacy global path + for smooth upgrade (the legacy file is read-only from here; the first + successful re-auth writes to the new per-tenant path). + Returns None if no record is found or the record cannot be deserialized. + """ + from azure.identity import AuthenticationRecord + for path in (_auth_record_path(tenant_id), _AUTH_RECORD_LEGACY_PATH): + if path.exists(): + try: + return AuthenticationRecord.deserialize(path.read_text(encoding="utf-8")) + except Exception: + continue # Corrupt or stale — try next + return None def load_env(): @@ -72,8 +110,8 @@ def get_credential(): The credential is cached for the lifetime of the process. Uses ClientSecretCredential when CLIENT_ID + CLIENT_SECRET are set, - otherwise falls back to DeviceCodeCredential with persistent OS-level - token caching. + otherwise falls back to InteractiveBrowserCredential with persistent + OS-level token caching. """ global _credential if _credential is not None: @@ -95,7 +133,7 @@ def get_credential(): try: from azure.identity import ( ClientSecretCredential, - DeviceCodeCredential, + InteractiveBrowserCredential, TokenCachePersistenceOptions, ) except ImportError: @@ -105,7 +143,7 @@ def get_credential(): # Warn if only one of CLIENT_ID / CLIENT_SECRET is set if bool(client_id) != bool(client_secret): print("WARNING: Only one of CLIENT_ID / CLIENT_SECRET is set. Both are required for", flush=True) - print(" service principal auth. Falling back to interactive device code flow.", flush=True) + print(" service principal auth. Falling back to interactive browser flow.", flush=True) # Path 1: Service principal (non-interactive) if client_id and client_secret: @@ -115,26 +153,17 @@ def get_credential(): client_secret=client_secret, ) else: - # Path 2: Device code flow (interactive) with persistent OS-level token cache. + # Path 2: Interactive browser flow with persistent OS-level token cache. # AuthenticationRecord tells the credential which cached account to silently - # refresh, avoiding a device code prompt on every new process. - from azure.identity import AuthenticationRecord + # refresh, avoiding a browser prompt on every new process. The record is + # stored per-tenant so switching tenants does not invalidate other tenants' + # records. Matches PAC CLI and MCP proxy auth UX (one browser sign-in, no + # code-typing). + auth_record = _read_auth_record(tenant_id) - auth_record = None - if _AUTH_RECORD_PATH.exists(): - try: - auth_record = AuthenticationRecord.deserialize(_AUTH_RECORD_PATH.read_text(encoding="utf-8")) - except Exception: - pass # Corrupt or stale record — will re-authenticate - - def _prompt_callback(verification_uri, user_code, _expires_on): - print(f"\nTo sign in, visit {verification_uri} and enter code: {user_code}", flush=True) - print("(Waiting for you to complete the login in your browser...)\n", flush=True) - - _credential = DeviceCodeCredential( + _credential = InteractiveBrowserCredential( tenant_id=tenant_id, client_id="51f81489-12ee-4a9e-aaae-a2591f45987d", # Well-known Microsoft Power Apps public client app ID - prompt_callback=_prompt_callback, cache_persistence_options=TokenCachePersistenceOptions( name="dataverse_cli", allow_unencrypted_storage=True, @@ -152,9 +181,11 @@ def get_token(scope=None): """ Acquire a raw access token string for the Dataverse environment. - On first call with a DeviceCodeCredential that has no saved AuthenticationRecord, - this triggers authenticate() to get the record and persist it. Subsequent calls - (same process or new processes) use silent refresh via the cached record + token cache. + On first call with an InteractiveBrowserCredential that has no saved + AuthenticationRecord for this tenant, this triggers authenticate() to get + the record and persist it at the per-tenant path. Subsequent calls (same + process or new processes, same tenant) use silent refresh via the cached + record + token cache. :param scope: OAuth2 scope. Defaults to "{DATAVERSE_URL}/.default". :returns: Access token string suitable for a Bearer Authorization header. @@ -162,18 +193,25 @@ def get_token(scope=None): global _auth_record_saved load_env() dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/") + tenant_id = os.environ.get("TENANT_ID") if not scope: scope = f"{dataverse_url}/.default" credential = get_credential() try: - from azure.identity import DeviceCodeCredential - if isinstance(credential, DeviceCodeCredential) and not _auth_record_saved and not _AUTH_RECORD_PATH.exists(): - # First login ever — use authenticate() to get and save the record + from azure.identity import InteractiveBrowserCredential + record_path = _auth_record_path(tenant_id) + if ( + isinstance(credential, InteractiveBrowserCredential) + and not _auth_record_saved + and not record_path.exists() + ): + # First login ever for this tenant — authenticate() to get and save + # the record at the per-tenant path. record = credential.authenticate(scopes=[scope]) - _AUTH_RECORD_PATH.parent.mkdir(parents=True, exist_ok=True) - _AUTH_RECORD_PATH.write_text(record.serialize(), encoding="utf-8") + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text(record.serialize(), encoding="utf-8") _auth_record_saved = True except Exception: pass # Fall through to normal get_token flow