Skip to content

Commit ddd5c27

Browse files
authored
[Identity] Implement new protocol for all credentials (#36882)
All credentials now implement the `SupportsTokenInfo/AsyncSupportsTokenInfo` protocol, by each having a `get_token_info` method implementation. This allows for more extensible authentication constructs. Signed-off-by: Paul Van Eck <[email protected]>
1 parent a60b09e commit ddd5c27

File tree

108 files changed

+3981
-1627
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

108 files changed

+3981
-1627
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
### Features Added
66

7+
- All credentials now support the `SupportsTokenInfo` protocol. Each credential now has a `get_token_info` method which returns an `AccessTokenInfo` object. The `get_token_info` method is an alternative method to `get_token` that improves support support for more complex authentication scenarios. ([#36882](https://github.com/Azure/azure-sdk-for-python/pull/36882))
8+
- Information on when a token should be refreshed is now saved in `AccessTokenInfo` (if available).
9+
710
### Breaking Changes
811

912
### Bugs Fixed
@@ -12,6 +15,7 @@
1215

1316
- Added identity config validation to `ManagedIdentityCredential` to avoid non-deterministic states (e.g. both `resource_id` and `object_id` are specified). ([#36950](https://github.com/Azure/azure-sdk-for-python/pull/36950))
1417
- Additional validation was added for `ManagedIdentityCredential` in Azure Cloud Shell environments. ([#36438](https://github.com/Azure/azure-sdk-for-python/issues/36438))
18+
- Bumped minimum dependency on `azure-core` to `>=1.31.0`.
1519

1620
## 1.18.0b2 (2024-08-09)
1721

sdk/identity/azure-identity/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/identity/azure-identity",
5-
"Tag": "python/identity/azure-identity_cb8dd6f319"
5+
"Tag": "python/identity/azure-identity_61e626a4a0"
66
}

sdk/identity/azure-identity/azure/identity/_bearer_token_provider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ------------------------------------
55
from typing import Callable
66

7-
from azure.core.credentials import TokenCredential
7+
from azure.core.credentials import TokenProvider
88
from azure.core.pipeline.policies import BearerTokenCredentialPolicy
99
from azure.core.pipeline import PipelineRequest, PipelineContext
1010
from azure.core.rest import HttpRequest
@@ -14,7 +14,7 @@ def _make_request() -> PipelineRequest[HttpRequest]:
1414
return PipelineRequest(HttpRequest("CredentialWrapper", "https://fakeurl"), PipelineContext(None))
1515

1616

17-
def get_bearer_token_provider(credential: TokenCredential, *scopes: str) -> Callable[[], str]:
17+
def get_bearer_token_provider(credential: TokenProvider, *scopes: str) -> Callable[[], str]:
1818
"""Returns a callable that provides a bearer token.
1919
2020
It can be used for instance to write code like:

sdk/identity/azure-identity/azure/identity/_credentials/application.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
# ------------------------------------
55
import logging
66
import os
7-
from typing import Any, Optional
7+
from typing import Any, Optional, cast
88

9-
from azure.core.credentials import AccessToken
9+
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions, SupportsTokenInfo, TokenCredential
1010
from .chained import ChainedTokenCredential
1111
from .environment import EnvironmentCredential
1212
from .managed_identity import ManagedIdentityCredential
@@ -83,10 +83,37 @@ def get_token(
8383
`message` attribute listing each authentication attempt and its error message.
8484
"""
8585
if self._successful_credential:
86-
token = self._successful_credential.get_token(*scopes, claims=claims, tenant_id=tenant_id, **kwargs)
86+
token = cast(TokenCredential, self._successful_credential).get_token(
87+
*scopes, claims=claims, tenant_id=tenant_id, **kwargs
88+
)
8789
_LOGGER.info(
8890
"%s acquired a token from %s", self.__class__.__name__, self._successful_credential.__class__.__name__
8991
)
9092
return token
9193

9294
return super(AzureApplicationCredential, self).get_token(*scopes, claims=claims, tenant_id=tenant_id, **kwargs)
95+
96+
def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] = None) -> AccessTokenInfo:
97+
"""Request an access token for `scopes`.
98+
99+
This is an alternative to `get_token` to enable certain scenarios that require additional properties
100+
on the token. This method is called automatically by Azure SDK clients.
101+
102+
:param str scopes: desired scopes for the access token. This method requires at least one scope.
103+
For more information about scopes, see https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
104+
:keyword options: A dictionary of options for the token request. Unknown options will be ignored. Optional.
105+
:paramtype options: ~azure.core.credentials.TokenRequestOptions
106+
107+
:rtype: AccessTokenInfo
108+
:return: An AccessTokenInfo instance containing information about the token.
109+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The exception has a
110+
`message` attribute listing each authentication attempt and its error message.
111+
"""
112+
if self._successful_credential:
113+
token_info = cast(SupportsTokenInfo, self._successful_credential).get_token_info(*scopes, options=options)
114+
_LOGGER.info(
115+
"%s acquired a token from %s", self.__class__.__name__, self._successful_credential.__class__.__name__
116+
)
117+
return token_info
118+
119+
return cast(SupportsTokenInfo, super()).get_token_info(*scopes, options=options)

sdk/identity/azure-identity/azure/identity/_credentials/authorization_code.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ------------------------------------
55
from typing import Optional, Any
66

7-
from azure.core.credentials import AccessToken
7+
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions
88
from azure.core.exceptions import ClientAuthenticationError
99
from .._internal.aad_client import AadClient
1010
from .._internal.get_token_mixin import GetTokenMixin
@@ -90,10 +90,35 @@ def get_token(
9090
*scopes, claims=claims, tenant_id=tenant_id, client_secret=self._client_secret, **kwargs
9191
)
9292

93-
def _acquire_token_silently(self, *scopes: str, **kwargs) -> Optional[AccessToken]:
93+
def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] = None) -> AccessTokenInfo:
94+
"""Request an access token for `scopes`.
95+
96+
This is an alternative to `get_token` to enable certain scenarios that require additional properties
97+
on the token. This method is called automatically by Azure SDK clients.
98+
99+
The first time this method is called, the credential will redeem its authorization code. On subsequent calls
100+
the credential will return a cached access token or redeem a refresh token, if it acquired a refresh token upon
101+
redeeming the authorization code.
102+
103+
:param str scopes: desired scopes for the access token. This method requires at least one scope.
104+
For more information about scopes, see https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
105+
:keyword options: A dictionary of options for the token request. Unknown options will be ignored. Optional.
106+
:paramtype options: ~azure.core.credentials.TokenRequestOptions
107+
108+
:rtype: AccessTokenInfo
109+
:return: An AccessTokenInfo instance containing information about the token.
110+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
111+
attribute gives a reason. Any error response from Microsoft Entra ID is available as the error's
112+
``response`` attribute.
113+
"""
114+
return super()._get_token_base(
115+
*scopes, options=options, client_secret=self._client_secret, base_method_name="get_token_info"
116+
)
117+
118+
def _acquire_token_silently(self, *scopes: str, **kwargs) -> Optional[AccessTokenInfo]:
94119
return self._client.get_cached_access_token(scopes, **kwargs)
95120

96-
def _request_token(self, *scopes: str, **kwargs) -> AccessToken:
121+
def _request_token(self, *scopes: str, **kwargs) -> AccessTokenInfo:
97122
if self._authorization_code:
98123
token = self._client.obtain_token_by_authorization_code(
99124
scopes=scopes, code=self._authorization_code, redirect_uri=self._redirect_uri, **kwargs

sdk/identity/azure-identity/azure/identity/_credentials/azd_cli.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import sys
1313
from typing import Any, Dict, List, Optional
1414

15-
from azure.core.credentials import AccessToken
15+
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions
1616
from azure.core.exceptions import ClientAuthenticationError
1717

1818
from .. import CredentialUnavailableError
@@ -118,10 +118,43 @@ def get_token(
118118
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked
119119
the Azure Developer CLI but didn't receive an access token.
120120
"""
121+
options: TokenRequestOptions = {}
122+
if tenant_id:
123+
options["tenant_id"] = tenant_id
124+
125+
token_info = self._get_token_base(*scopes, options=options, **kwargs)
126+
return AccessToken(token_info.token, token_info.expires_on)
127+
128+
@log_get_token
129+
def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] = None) -> AccessTokenInfo:
130+
"""Request an access token for `scopes`.
131+
132+
This is an alternative to `get_token` to enable certain scenarios that require additional properties
133+
on the token. This method is called automatically by Azure SDK clients. Applications calling this method
134+
directly must also handle token caching because this credential doesn't cache the tokens it acquires.
135+
136+
:param str scopes: desired scopes for the access token. This method requires at least one scope.
137+
For more information about scopes, see https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
138+
:keyword options: A dictionary of options for the token request. Unknown options will be ignored. Optional.
139+
:paramtype options: ~azure.core.credentials.TokenRequestOptions
140+
141+
:rtype: AccessTokenInfo
142+
:return: An AccessTokenInfo instance containing information about the token.
143+
144+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke
145+
the Azure Developer CLI.
146+
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked
147+
the Azure Developer CLI but didn't receive an access token.
148+
"""
149+
return self._get_token_base(*scopes, options=options)
121150

151+
def _get_token_base(
152+
self, *scopes: str, options: Optional[TokenRequestOptions] = None, **kwargs: Any
153+
) -> AccessTokenInfo:
122154
if not scopes:
123155
raise ValueError("Missing scope in request. \n")
124156

157+
tenant_id = options.get("tenant_id") if options else None
125158
if tenant_id:
126159
validate_tenant_id(tenant_id)
127160
for scope in scopes:
@@ -154,7 +187,7 @@ def get_token(
154187
return token
155188

156189

157-
def parse_token(output: str) -> Optional[AccessToken]:
190+
def parse_token(output: str) -> Optional[AccessTokenInfo]:
158191
"""Parse to an AccessToken.
159192
160193
In particular, convert the "expiresOn" value to epoch seconds. This value is a naive local datetime as returned by
@@ -169,7 +202,7 @@ def parse_token(output: str) -> Optional[AccessToken]:
169202
dt = datetime.strptime(token["expiresOn"], "%Y-%m-%dT%H:%M:%SZ")
170203
expires_on = dt.timestamp()
171204

172-
return AccessToken(token["token"], int(expires_on))
205+
return AccessTokenInfo(token["token"], int(expires_on))
173206
except (KeyError, ValueError):
174207
return None
175208

sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import sys
1212
from typing import List, Optional, Any, Dict
1313

14-
from azure.core.credentials import AccessToken
14+
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions
1515
from azure.core.exceptions import ClientAuthenticationError
1616

1717
from .. import CredentialUnavailableError
@@ -94,6 +94,41 @@ def get_token(
9494
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
9595
receive an access token.
9696
"""
97+
98+
options: TokenRequestOptions = {}
99+
if tenant_id:
100+
options["tenant_id"] = tenant_id
101+
102+
token_info = self._get_token_base(*scopes, options=options, **kwargs)
103+
return AccessToken(token_info.token, token_info.expires_on)
104+
105+
@log_get_token
106+
def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] = None) -> AccessTokenInfo:
107+
"""Request an access token for `scopes`.
108+
109+
This is an alternative to `get_token` to enable certain scenarios that require additional properties
110+
on the token. This method is called automatically by Azure SDK clients. Applications calling this method
111+
directly must also handle token caching because this credential doesn't cache the tokens it acquires.
112+
113+
:param str scopes: desired scopes for the access token. This credential allows only one scope per request.
114+
For more information about scopes, see https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
115+
:keyword options: A dictionary of options for the token request. Unknown options will be ignored. Optional.
116+
:paramtype options: ~azure.core.credentials.TokenRequestOptions
117+
118+
:rtype: AccessTokenInfo
119+
:return: An AccessTokenInfo instance containing information about the token.
120+
121+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
122+
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
123+
receive an access token.
124+
"""
125+
return self._get_token_base(*scopes, options=options)
126+
127+
def _get_token_base(
128+
self, *scopes: str, options: Optional[TokenRequestOptions] = None, **kwargs: Any
129+
) -> AccessTokenInfo:
130+
131+
tenant_id = options.get("tenant_id") if options else None
97132
if tenant_id:
98133
validate_tenant_id(tenant_id)
99134
for scope in scopes:
@@ -126,7 +161,7 @@ def get_token(
126161
return token
127162

128163

129-
def parse_token(output) -> Optional[AccessToken]:
164+
def parse_token(output) -> Optional[AccessTokenInfo]:
130165
"""Parse output of 'az account get-access-token' to an AccessToken.
131166
132167
In particular, convert the "expiresOn" value to epoch seconds. This value is a naive local datetime as returned by
@@ -141,11 +176,11 @@ def parse_token(output) -> Optional[AccessToken]:
141176

142177
# Use "expires_on" if it's present, otherwise use "expiresOn".
143178
if "expires_on" in token:
144-
return AccessToken(token["accessToken"], int(token["expires_on"]))
179+
return AccessTokenInfo(token["accessToken"], int(token["expires_on"]))
145180

146181
dt = datetime.strptime(token["expiresOn"], "%Y-%m-%d %H:%M:%S.%f")
147182
expires_on = dt.timestamp()
148-
return AccessToken(token["accessToken"], int(expires_on))
183+
return AccessTokenInfo(token["accessToken"], int(expires_on))
149184
except (KeyError, ValueError):
150185
return None
151186

sdk/identity/azure-identity/azure/identity/_credentials/azure_pipelines.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any, Optional
88

99
from azure.core.exceptions import ClientAuthenticationError
10-
from azure.core.credentials import AccessToken
10+
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions
1111
from azure.core.rest import HttpRequest, HttpResponse
1212

1313
from .client_assertion import ClientAssertionCredential
@@ -125,6 +125,25 @@ def get_token(
125125
*scopes, claims=claims, tenant_id=tenant_id, enable_cae=enable_cae, **kwargs
126126
)
127127

128+
def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] = None) -> AccessTokenInfo:
129+
"""Request an access token for `scopes`.
130+
131+
This is an alternative to `get_token` to enable certain scenarios that require additional properties
132+
on the token. This method is called automatically by Azure SDK clients.
133+
134+
:param str scopes: desired scope for the access token. This method requires at least one scope.
135+
For more information about scopes, see https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
136+
:keyword options: A dictionary of options for the token request. Unknown options will be ignored. Optional.
137+
:paramtype options: ~azure.core.credentials.TokenRequestOptions
138+
139+
:rtype: AccessTokenInfo
140+
:return: An AccessTokenInfo instance containing information about the token.
141+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
142+
attribute gives a reason.
143+
"""
144+
validate_env_vars()
145+
return self._client_assertion_credential.get_token_info(*scopes, options=options)
146+
128147
def _get_oidc_token(self) -> str:
129148
request = build_oidc_request(self._service_connection_id, self._system_access_token)
130149
response = self._pipeline.run(request, retry_on_methods=[request.method])

sdk/identity/azure-identity/azure/identity/_credentials/azure_powershell.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import sys
99
from typing import Any, List, Tuple, Optional
1010

11-
from azure.core.credentials import AccessToken
11+
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions
1212
from azure.core.exceptions import ClientAuthenticationError
1313

1414
from .azure_cli import get_safe_working_dir
@@ -125,6 +125,42 @@ def get_token(
125125
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked Azure PowerShell but didn't
126126
receive an access token
127127
"""
128+
129+
options: TokenRequestOptions = {}
130+
if tenant_id:
131+
options["tenant_id"] = tenant_id
132+
133+
token_info = self._get_token_base(*scopes, options=options, **kwargs)
134+
return AccessToken(token_info.token, token_info.expires_on)
135+
136+
@log_get_token
137+
def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] = None) -> AccessTokenInfo:
138+
"""Request an access token for `scopes`.
139+
140+
This is an alternative to `get_token` to enable certain scenarios that require additional properties
141+
on the token. This method is called automatically by Azure SDK clients. Applications calling this method
142+
directly must also handle token caching because this credential doesn't cache the tokens it acquires.
143+
144+
:param str scopes: desired scopes for the access token. TThis credential allows only one scope per request.
145+
For more information about scopes, see https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
146+
:keyword options: A dictionary of options for the token request. Unknown options will be ignored. Optional.
147+
:paramtype options: ~azure.core.credentials.TokenRequestOptions
148+
149+
:rtype: AccessTokenInfo
150+
:return: An AccessTokenInfo instance containing information about the token.
151+
152+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke Azure PowerShell, or
153+
no account is authenticated
154+
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked Azure PowerShell but didn't
155+
receive an access token
156+
"""
157+
return self._get_token_base(*scopes, options=options)
158+
159+
def _get_token_base(
160+
self, *scopes: str, options: Optional[TokenRequestOptions] = None, **kwargs: Any
161+
) -> AccessTokenInfo:
162+
163+
tenant_id = options.get("tenant_id") if options else None
128164
if tenant_id:
129165
validate_tenant_id(tenant_id)
130166
for scope in scopes:
@@ -185,11 +221,11 @@ def start_process(args: List[str]) -> "subprocess.Popen":
185221
return proc
186222

187223

188-
def parse_token(output: str) -> AccessToken:
224+
def parse_token(output: str) -> AccessTokenInfo:
189225
for line in output.split():
190226
if line.startswith("azsdk%"):
191227
_, token, expires_on = line.split("%")
192-
return AccessToken(token, int(expires_on))
228+
return AccessTokenInfo(token, int(expires_on))
193229

194230
if within_dac.get():
195231
raise CredentialUnavailableError(message='Unexpected output from Get-AzAccessToken: "{}"'.format(output))

0 commit comments

Comments
 (0)