From f33a72af0c9bf142660b0b69bb29c273d283628f Mon Sep 17 00:00:00 2001 From: Pavan Yaduraj Athani Date: Thu, 14 May 2026 15:29:00 +0530 Subject: [PATCH] Add acquire_token() public method for cross-resource auth Adds _AuthManager.acquire_token(resource_url) as a thin public helper over the existing _acquire_token: appends /.default and delegates to the underlying TokenCredential. Lets callers reuse the same credential to obtain tokens for any Microsoft AAD-protected resource (notably a linked Finance & Operations env) via client.auth.acquire_token(fno_url). Internal _ODataClient._headers() now goes through the same method, so the DV and external paths share one scope-construction site. Verified end-to-end against a real F&O int env (operations.int.dynamics.com) using AzureCliCredential: token issued with aud=, F&O accepted the token and returned $metadata (HTTP 200, ~53 MB OData XML). Tests: 4 new unit tests in tests/unit/core/test_auth.py (default-scope, trailing-slash strip, alternate resource, empty-URL ValueError); _auth.py coverage 100%. Inline DummyAuth in tests/conftest.py plus three test modules updated to also expose acquire_token so _odata._headers callsites keep passing. Full suite: 1393 passed. Docs: README adds a subsection covering F&O token acquisition; both SKILL.md copies updated (byte-identical per dataverse-sdk-dev contract); CHANGELOG [Unreleased] entry added. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/dataverse-sdk-use/SKILL.md | 14 +++++ CHANGELOG.md | 3 + README.md | 13 +++++ .../claude_skill/dataverse-sdk-use/SKILL.md | 14 +++++ src/PowerPlatform/Dataverse/core/_auth.py | 57 +++++++++++++++++-- src/PowerPlatform/Dataverse/data/_odata.py | 3 +- tests/conftest.py | 10 +++- tests/unit/core/test_auth.py | 43 ++++++++++++++ tests/unit/core/test_http_errors.py | 3 + .../unit/data/test_enum_optionset_payload.py | 3 + tests/unit/data/test_logical_crud.py | 3 + 11 files changed, 159 insertions(+), 7 deletions(-) diff --git a/.claude/skills/dataverse-sdk-use/SKILL.md b/.claude/skills/dataverse-sdk-use/SKILL.md index 72677468..a2d9ec4f 100644 --- a/.claude/skills/dataverse-sdk-use/SKILL.md +++ b/.claude/skills/dataverse-sdk-use/SKILL.md @@ -67,6 +67,20 @@ with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client: client = DataverseClient("https://yourorg.crm.dynamics.com", credential) ``` +### Acquiring Tokens for Other Microsoft Resources + +`client.auth.acquire_token(resource_url)` returns an OAuth2 token from the same credential for any AAD-protected Microsoft resource (e.g. a linked Finance & Operations environment). The `/.default` scope is appended automatically. + +```python +# Token for a linked Finance & Operations environment +fno_token = client.auth.acquire_token("https://myenv.operations.dynamics.com") + +# Use the F&O token to call F&O OData / Custom Service endpoints directly +headers = {"Authorization": f"Bearer {fno_token}"} +``` + +The customer's AAD app must already have the required permission on the target resource. For F&O the standard delegated permissions are `Odata.FullAccess` and `CustomService.FullAccess` on the **Microsoft Dynamics ERP** API (`00000015-0000-0000-c000-000000000000`). + ### CRUD Operations #### Create Records diff --git a/CHANGELOG.md b/CHANGELOG.md index cf36ae04..10967256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `client.auth.acquire_token(resource_url)` -- acquire an OAuth2 access token for any Microsoft AAD-protected resource (for example a linked Finance & Operations environment) using the same credential the Dataverse client was constructed with. The `/.default` scope is appended automatically; token caching and refresh remain the credential's responsibility. The internal Dataverse request path now goes through the same method, removing the inline scope construction in `_ODataClient._headers()`. + ## [0.1.0b10] - 2026-05-12 ### Added diff --git a/README.md b/README.md index eab08734..e2d5059b 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,19 @@ client = DataverseClient("https://yourorg.crm.dynamics.com", credential) > **Complete authentication setup**: See **[Use OAuth with Dataverse](https://learn.microsoft.com/power-apps/developer/data-platform/authenticate-oauth)** for app registration, all credential types, and security configuration. +#### Acquiring tokens for other Microsoft resources + +The same credential can be used to acquire tokens for any Microsoft AAD-protected resource the caller has access to -- notably a Finance & Operations environment linked to the same Dataverse org. Use `client.auth.acquire_token(resource_url)` to obtain a token without constructing a second credential: + +```python +# Token for a linked Finance & Operations environment +fno_token = client.auth.acquire_token("https://myenv.operations.dynamics.com") + +headers = {"Authorization": f"Bearer {fno_token}"} +``` + +The `/.default` scope is appended automatically. The customer's AAD app must already have the required permission on the target resource and admin consent granted. For Finance & Operations the standard permissions are `Odata.FullAccess` and `CustomService.FullAccess` on the **Microsoft Dynamics ERP** API (`00000015-0000-0000-c000-000000000000`). + ## Key concepts The SDK provides a simple, pythonic interface for Dataverse operations: diff --git a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md index 72677468..a2d9ec4f 100644 --- a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md +++ b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md @@ -67,6 +67,20 @@ with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client: client = DataverseClient("https://yourorg.crm.dynamics.com", credential) ``` +### Acquiring Tokens for Other Microsoft Resources + +`client.auth.acquire_token(resource_url)` returns an OAuth2 token from the same credential for any AAD-protected Microsoft resource (e.g. a linked Finance & Operations environment). The `/.default` scope is appended automatically. + +```python +# Token for a linked Finance & Operations environment +fno_token = client.auth.acquire_token("https://myenv.operations.dynamics.com") + +# Use the F&O token to call F&O OData / Custom Service endpoints directly +headers = {"Authorization": f"Bearer {fno_token}"} +``` + +The customer's AAD app must already have the required permission on the target resource. For F&O the standard delegated permissions are `Odata.FullAccess` and `CustomService.FullAccess` on the **Microsoft Dynamics ERP** API (`00000015-0000-0000-c000-000000000000`). + ### CRUD Operations #### Create Records diff --git a/src/PowerPlatform/Dataverse/core/_auth.py b/src/PowerPlatform/Dataverse/core/_auth.py index f5dcca68..98042fbb 100644 --- a/src/PowerPlatform/Dataverse/core/_auth.py +++ b/src/PowerPlatform/Dataverse/core/_auth.py @@ -2,11 +2,12 @@ # Licensed under the MIT license. """ -Authentication helpers for Dataverse. +Authentication helpers. This module provides :class:`~PowerPlatform.Dataverse.core._auth._AuthManager`, a thin wrapper over any Azure Identity -``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse.core._auth._TokenPair` for -storing the acquired token alongside its scope. +``TokenCredential`` for acquiring OAuth2 access tokens for Microsoft AAD-protected resources -- Dataverse by default, +and any other resource (e.g. a linked Finance & Operations environment) when an explicit scope is supplied -- +and :class:`~PowerPlatform.Dataverse.core._auth._TokenPair` for storing the acquired token alongside its scope. """ from __future__ import annotations @@ -33,7 +34,15 @@ class _TokenPair: class _AuthManager: """ - Azure Identity-based authentication manager for Dataverse. + Azure Identity-based authentication manager. + + Resource-agnostic: the scope passed to :meth:`_acquire_token` selects + the target resource. The Dataverse client supplies its own + ``/.default`` scope on every internal request via + :meth:`acquire_token`, and the same method can be called externally + (through ``client.auth.acquire_token(...)``) to obtain tokens for + other Microsoft AAD-protected resources -- for example a linked + Finance & Operations environment. :param credential: Azure Identity credential implementation. :type credential: ~azure.core.credentials.TokenCredential @@ -57,3 +66,43 @@ def _acquire_token(self, scope: str) -> _TokenPair: """ token = self.credential.get_token(scope) return _TokenPair(resource=scope, access_token=token.token) + + def acquire_token(self, resource_url: str) -> str: + """ + Acquire an OAuth2 access token for a Microsoft AAD-protected resource. + + Resource-agnostic helper: pass the resource URL (Dataverse env URL + for Dataverse, Finance & Operations env URL for F&O, etc.) and the + ``/.default`` scope suffix is appended automatically before + delegating to the underlying credential. Token caching, refresh, + and silent reauthentication are the credential's responsibility; + Azure Identity credentials cache in-memory by default so repeated + calls are cheap. + + :param resource_url: Resource URL for the target Microsoft service + (for example ``"https://myenv.operations.dynamics.com"``). + Trailing slash is removed before scope construction. + :type resource_url: :class:`str` + + :return: OAuth2 access token string suitable for placing in an + ``Authorization: Bearer ...`` header. + :rtype: :class:`str` + + :raises ValueError: If ``resource_url`` is empty after trimming. + :raises ~azure.core.exceptions.ClientAuthenticationError: If token + acquisition fails. + + Example: + Acquire a token for a linked Finance & Operations environment + using the same credential the Dataverse client was built with:: + + client = DataverseClient(dv_url, credential) + fno_token = client.auth.acquire_token( + "https://myenv.operations.dynamics.com" + ) + """ + target = (resource_url or "").rstrip("/") + if not target: + raise ValueError("resource_url must not be empty.") + scope = f"{target}/.default" + return self._acquire_token(scope).access_token diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index b6cdf29a..763bb923 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -241,8 +241,7 @@ def close(self) -> None: def _headers(self) -> Dict[str, str]: """Build standard OData headers with bearer auth.""" - scope = f"{self.base_url}/.default" - token = self.auth._acquire_token(scope).access_token + token = self.auth.acquire_token(self.base_url) ua = _USER_AGENT if self._operation_context: ua = f"{_USER_AGENT} ({self._operation_context})" diff --git a/tests/conftest.py b/tests/conftest.py index 8532e063..657b629e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,12 @@ @pytest.fixture def dummy_auth(): - """Mock authentication object for testing.""" + """Mock authentication object for testing. + + Mirrors the real ``_AuthManager`` surface: both the internal + ``_acquire_token(scope)`` (used directly by older tests) and the public + ``acquire_token(resource_url)`` (used by ``_ODataClient._headers``). + """ class DummyAuth: def _acquire_token(self, scope): @@ -24,6 +29,9 @@ class Token: return Token() + def acquire_token(self, resource_url): + return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token + return DummyAuth() diff --git a/tests/unit/core/test_auth.py b/tests/unit/core/test_auth.py index b82bb9bd..97bbc945 100644 --- a/tests/unit/core/test_auth.py +++ b/tests/unit/core/test_auth.py @@ -33,3 +33,46 @@ def test_acquire_token_returns_token_pair(self): self.assertIsInstance(result, _TokenPair) self.assertEqual(result.resource, "https://org.crm.dynamics.com/.default") self.assertEqual(result.access_token, "my-access-token") + + def test_acquire_token_public_appends_default_scope(self): + """acquire_token appends /.default to the resource URL and returns the access_token string.""" + mock_credential = MagicMock(spec=TokenCredential) + mock_credential.get_token.return_value = MagicMock(token="dv-token") + + manager = _AuthManager(mock_credential) + result = manager.acquire_token("https://org.crm.dynamics.com") + + mock_credential.get_token.assert_called_once_with("https://org.crm.dynamics.com/.default") + self.assertEqual(result, "dv-token") + + def test_acquire_token_public_strips_trailing_slash(self): + """acquire_token strips a trailing slash before constructing the scope.""" + mock_credential = MagicMock(spec=TokenCredential) + mock_credential.get_token.return_value = MagicMock(token="t") + + manager = _AuthManager(mock_credential) + manager.acquire_token("https://myenv.operations.dynamics.com/") + + mock_credential.get_token.assert_called_once_with("https://myenv.operations.dynamics.com/.default") + + def test_acquire_token_public_supports_alternate_resource(self): + """acquire_token works for any resource URL (e.g. linked Finance & Operations env).""" + mock_credential = MagicMock(spec=TokenCredential) + mock_credential.get_token.return_value = MagicMock(token="fno-token") + + manager = _AuthManager(mock_credential) + result = manager.acquire_token("https://myenv.operations.dynamics.com") + + mock_credential.get_token.assert_called_once_with("https://myenv.operations.dynamics.com/.default") + self.assertEqual(result, "fno-token") + + def test_acquire_token_public_empty_url_raises(self): + """acquire_token raises ValueError when resource_url is empty after trim and does not call get_token.""" + mock_credential = MagicMock(spec=TokenCredential) + manager = _AuthManager(mock_credential) + + with self.assertRaises(ValueError): + manager.acquire_token("") + with self.assertRaises(ValueError): + manager.acquire_token("/") + mock_credential.get_token.assert_not_called() diff --git a/tests/unit/core/test_http_errors.py b/tests/unit/core/test_http_errors.py index 39373e05..4c60514f 100644 --- a/tests/unit/core/test_http_errors.py +++ b/tests/unit/core/test_http_errors.py @@ -17,6 +17,9 @@ class T: return T() + def acquire_token(self, resource_url): + return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token + class DummyHTTP: def __init__(self, responses): diff --git a/tests/unit/data/test_enum_optionset_payload.py b/tests/unit/data/test_enum_optionset_payload.py index 6287daf2..f5ca10e9 100644 --- a/tests/unit/data/test_enum_optionset_payload.py +++ b/tests/unit/data/test_enum_optionset_payload.py @@ -14,6 +14,9 @@ class T: return T() + def acquire_token(self, resource_url): # pragma: no cover - simple stub + return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token + class DummyConfig: """Minimal config stub providing attributes _ODataClient.__init__ expects.""" diff --git a/tests/unit/data/test_logical_crud.py b/tests/unit/data/test_logical_crud.py index 2096a4d5..4f80ff8c 100644 --- a/tests/unit/data/test_logical_crud.py +++ b/tests/unit/data/test_logical_crud.py @@ -14,6 +14,9 @@ class T: return T() + def acquire_token(self, resource_url): + return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token + class DummyHTTPClient: def __init__(self, responses):