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