Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
]
Expand Down
2 changes: 1 addition & 1 deletion .github/plugins/dataverse/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .github/plugins/dataverse/.github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
108 changes: 73 additions & 35 deletions .github/plugins/dataverse/scripts/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -95,7 +133,7 @@ def get_credential():
try:
from azure.identity import (
ClientSecretCredential,
DeviceCodeCredential,
InteractiveBrowserCredential,
TokenCachePersistenceOptions,
)
except ImportError:
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -152,28 +181,37 @@ 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.
"""
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
Expand Down
Loading