Skip to content

Commit f33a72a

Browse files
Pavan Yaduraj Athaniclaude
andcommitted
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=<fno_url>, 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) <noreply@anthropic.com>
1 parent 5eae887 commit f33a72a

11 files changed

Lines changed: 159 additions & 7 deletions

File tree

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
6767
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
6868
```
6969

70+
### Acquiring Tokens for Other Microsoft Resources
71+
72+
`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.
73+
74+
```python
75+
# Token for a linked Finance & Operations environment
76+
fno_token = client.auth.acquire_token("https://myenv.operations.dynamics.com")
77+
78+
# Use the F&O token to call F&O OData / Custom Service endpoints directly
79+
headers = {"Authorization": f"Bearer {fno_token}"}
80+
```
81+
82+
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`).
83+
7084
### CRUD Operations
7185

7286
#### Create Records

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `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()`.
12+
1013
## [0.1.0b10] - 2026-05-12
1114

1215
### Added

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
112112

113113
> **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.
114114
115+
#### Acquiring tokens for other Microsoft resources
116+
117+
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:
118+
119+
```python
120+
# Token for a linked Finance & Operations environment
121+
fno_token = client.auth.acquire_token("https://myenv.operations.dynamics.com")
122+
123+
headers = {"Authorization": f"Bearer {fno_token}"}
124+
```
125+
126+
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`).
127+
115128
## Key concepts
116129

117130
The SDK provides a simple, pythonic interface for Dataverse operations:

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
6767
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
6868
```
6969

70+
### Acquiring Tokens for Other Microsoft Resources
71+
72+
`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.
73+
74+
```python
75+
# Token for a linked Finance & Operations environment
76+
fno_token = client.auth.acquire_token("https://myenv.operations.dynamics.com")
77+
78+
# Use the F&O token to call F&O OData / Custom Service endpoints directly
79+
headers = {"Authorization": f"Bearer {fno_token}"}
80+
```
81+
82+
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`).
83+
7084
### CRUD Operations
7185

7286
#### Create Records

src/PowerPlatform/Dataverse/core/_auth.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
# Licensed under the MIT license.
33

44
"""
5-
Authentication helpers for Dataverse.
5+
Authentication helpers.
66
77
This module provides :class:`~PowerPlatform.Dataverse.core._auth._AuthManager`, a thin wrapper over any Azure Identity
8-
``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse.core._auth._TokenPair` for
9-
storing the acquired token alongside its scope.
8+
``TokenCredential`` for acquiring OAuth2 access tokens for Microsoft AAD-protected resources -- Dataverse by default,
9+
and any other resource (e.g. a linked Finance & Operations environment) when an explicit scope is supplied --
10+
and :class:`~PowerPlatform.Dataverse.core._auth._TokenPair` for storing the acquired token alongside its scope.
1011
"""
1112

1213
from __future__ import annotations
@@ -33,7 +34,15 @@ class _TokenPair:
3334

3435
class _AuthManager:
3536
"""
36-
Azure Identity-based authentication manager for Dataverse.
37+
Azure Identity-based authentication manager.
38+
39+
Resource-agnostic: the scope passed to :meth:`_acquire_token` selects
40+
the target resource. The Dataverse client supplies its own
41+
``<base_url>/.default`` scope on every internal request via
42+
:meth:`acquire_token`, and the same method can be called externally
43+
(through ``client.auth.acquire_token(...)``) to obtain tokens for
44+
other Microsoft AAD-protected resources -- for example a linked
45+
Finance & Operations environment.
3746
3847
:param credential: Azure Identity credential implementation.
3948
:type credential: ~azure.core.credentials.TokenCredential
@@ -57,3 +66,43 @@ def _acquire_token(self, scope: str) -> _TokenPair:
5766
"""
5867
token = self.credential.get_token(scope)
5968
return _TokenPair(resource=scope, access_token=token.token)
69+
70+
def acquire_token(self, resource_url: str) -> str:
71+
"""
72+
Acquire an OAuth2 access token for a Microsoft AAD-protected resource.
73+
74+
Resource-agnostic helper: pass the resource URL (Dataverse env URL
75+
for Dataverse, Finance & Operations env URL for F&O, etc.) and the
76+
``/.default`` scope suffix is appended automatically before
77+
delegating to the underlying credential. Token caching, refresh,
78+
and silent reauthentication are the credential's responsibility;
79+
Azure Identity credentials cache in-memory by default so repeated
80+
calls are cheap.
81+
82+
:param resource_url: Resource URL for the target Microsoft service
83+
(for example ``"https://myenv.operations.dynamics.com"``).
84+
Trailing slash is removed before scope construction.
85+
:type resource_url: :class:`str`
86+
87+
:return: OAuth2 access token string suitable for placing in an
88+
``Authorization: Bearer ...`` header.
89+
:rtype: :class:`str`
90+
91+
:raises ValueError: If ``resource_url`` is empty after trimming.
92+
:raises ~azure.core.exceptions.ClientAuthenticationError: If token
93+
acquisition fails.
94+
95+
Example:
96+
Acquire a token for a linked Finance & Operations environment
97+
using the same credential the Dataverse client was built with::
98+
99+
client = DataverseClient(dv_url, credential)
100+
fno_token = client.auth.acquire_token(
101+
"https://myenv.operations.dynamics.com"
102+
)
103+
"""
104+
target = (resource_url or "").rstrip("/")
105+
if not target:
106+
raise ValueError("resource_url must not be empty.")
107+
scope = f"{target}/.default"
108+
return self._acquire_token(scope).access_token

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,7 @@ def close(self) -> None:
241241

242242
def _headers(self) -> Dict[str, str]:
243243
"""Build standard OData headers with bearer auth."""
244-
scope = f"{self.base_url}/.default"
245-
token = self.auth._acquire_token(scope).access_token
244+
token = self.auth.acquire_token(self.base_url)
246245
ua = _USER_AGENT
247246
if self._operation_context:
248247
ua = f"{_USER_AGENT} ({self._operation_context})"

tests/conftest.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515

1616
@pytest.fixture
1717
def dummy_auth():
18-
"""Mock authentication object for testing."""
18+
"""Mock authentication object for testing.
19+
20+
Mirrors the real ``_AuthManager`` surface: both the internal
21+
``_acquire_token(scope)`` (used directly by older tests) and the public
22+
``acquire_token(resource_url)`` (used by ``_ODataClient._headers``).
23+
"""
1924

2025
class DummyAuth:
2126
def _acquire_token(self, scope):
@@ -24,6 +29,9 @@ class Token:
2429

2530
return Token()
2631

32+
def acquire_token(self, resource_url):
33+
return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token
34+
2735
return DummyAuth()
2836

2937

tests/unit/core/test_auth.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,46 @@ def test_acquire_token_returns_token_pair(self):
3333
self.assertIsInstance(result, _TokenPair)
3434
self.assertEqual(result.resource, "https://org.crm.dynamics.com/.default")
3535
self.assertEqual(result.access_token, "my-access-token")
36+
37+
def test_acquire_token_public_appends_default_scope(self):
38+
"""acquire_token appends /.default to the resource URL and returns the access_token string."""
39+
mock_credential = MagicMock(spec=TokenCredential)
40+
mock_credential.get_token.return_value = MagicMock(token="dv-token")
41+
42+
manager = _AuthManager(mock_credential)
43+
result = manager.acquire_token("https://org.crm.dynamics.com")
44+
45+
mock_credential.get_token.assert_called_once_with("https://org.crm.dynamics.com/.default")
46+
self.assertEqual(result, "dv-token")
47+
48+
def test_acquire_token_public_strips_trailing_slash(self):
49+
"""acquire_token strips a trailing slash before constructing the scope."""
50+
mock_credential = MagicMock(spec=TokenCredential)
51+
mock_credential.get_token.return_value = MagicMock(token="t")
52+
53+
manager = _AuthManager(mock_credential)
54+
manager.acquire_token("https://myenv.operations.dynamics.com/")
55+
56+
mock_credential.get_token.assert_called_once_with("https://myenv.operations.dynamics.com/.default")
57+
58+
def test_acquire_token_public_supports_alternate_resource(self):
59+
"""acquire_token works for any resource URL (e.g. linked Finance & Operations env)."""
60+
mock_credential = MagicMock(spec=TokenCredential)
61+
mock_credential.get_token.return_value = MagicMock(token="fno-token")
62+
63+
manager = _AuthManager(mock_credential)
64+
result = manager.acquire_token("https://myenv.operations.dynamics.com")
65+
66+
mock_credential.get_token.assert_called_once_with("https://myenv.operations.dynamics.com/.default")
67+
self.assertEqual(result, "fno-token")
68+
69+
def test_acquire_token_public_empty_url_raises(self):
70+
"""acquire_token raises ValueError when resource_url is empty after trim and does not call get_token."""
71+
mock_credential = MagicMock(spec=TokenCredential)
72+
manager = _AuthManager(mock_credential)
73+
74+
with self.assertRaises(ValueError):
75+
manager.acquire_token("")
76+
with self.assertRaises(ValueError):
77+
manager.acquire_token("/")
78+
mock_credential.get_token.assert_not_called()

tests/unit/core/test_http_errors.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class T:
1717

1818
return T()
1919

20+
def acquire_token(self, resource_url):
21+
return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token
22+
2023

2124
class DummyHTTP:
2225
def __init__(self, responses):

tests/unit/data/test_enum_optionset_payload.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ class T:
1414

1515
return T()
1616

17+
def acquire_token(self, resource_url): # pragma: no cover - simple stub
18+
return self._acquire_token(f"{(resource_url or '').rstrip('/')}/.default").access_token
19+
1720

1821
class DummyConfig:
1922
"""Minimal config stub providing attributes _ODataClient.__init__ expects."""

0 commit comments

Comments
 (0)