-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Implement authorization code flow #7379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
2645ed7
synchronous AAD client wrapping MSAL's oauth2 client
chlowell d299401
synchronous AuthorizationCodeCredential
chlowell 55e2da5
async wrapper for requests Session
chlowell 5343e2f
async AuthorizationCodeCredential
chlowell a591a77
aiohttp is a dev requirement
chlowell 15735f2
tests
chlowell 6370135
update HISTORY
chlowell afb7d71
factor out secret scrubbing
chlowell 0d301ad
configurable authority
chlowell 40961ce
paint it black
chlowell 7a62498
test URLs more thoroughly
chlowell 4cea49f
obtain_token_by_refresh_token must pass kwargs
chlowell 600c61f
ensure loop is passed in kwargs
chlowell f7c50bc
return a passable mock response
chlowell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
75 changes: 75 additions & 0 deletions
75
sdk/identity/azure-identity/azure/identity/_credentials/authorization_code.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from azure.core.exceptions import ClientAuthenticationError | ||
| from .._internal.aad_client import AadClient | ||
|
|
||
| if TYPE_CHECKING: | ||
| # pylint:disable=unused-import,ungrouped-imports | ||
| from typing import Any, Iterable, Optional | ||
| from azure.core.credentials import AccessToken | ||
|
|
||
|
|
||
| class AuthorizationCodeCredential(object): | ||
| """ | ||
| Authenticates by redeeming an authorization code previously obtained from Azure Active Directory. | ||
| See https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow for more information | ||
| about the authentication flow. | ||
|
|
||
| :param str client_id: the application's client ID | ||
| :param str tenant_id: ID of the application's Azure Active Directory tenant. Also called its 'directory' ID. | ||
| :param str authorization_code: the authorization code from the user's log-in | ||
| :param str redirect_uri: The application's redirect URI. Must match the URI used to request the authorization code. | ||
| :param str client_secret: One of the application's client secrets. Required only for web apps and web APIs. | ||
|
|
||
| Keyword arguments | ||
| - **authority**: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com', the | ||
| authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities` defines | ||
| authorities for other clouds. | ||
| """ | ||
|
|
||
| def __init__(self, client_id, tenant_id, authorization_code, redirect_uri, client_secret=None, **kwargs): | ||
| # type: (str, str, str, str, Optional[str], **Any) -> None | ||
| self._authorization_code = authorization_code # type: Optional[str] | ||
| self._client_id = client_id | ||
| self._client_secret = client_secret | ||
| self._client = kwargs.pop("client", None) or AadClient(client_id, tenant_id, **kwargs) | ||
| self._redirect_uri = redirect_uri | ||
|
|
||
| def get_token(self, *scopes, **kwargs): | ||
| # type: (*str, **Any) -> AccessToken | ||
| """ | ||
| Request an access token for ``scopes``. The first time this method is called, the credential will redeem its | ||
| authorization code. On subsequent calls the credential will return a cached access token or redeem a refresh | ||
| token, if it acquired a refresh token upon redeeming the authorization code. | ||
|
|
||
| :param str scopes: desired scopes for the access token | ||
| :rtype: :class:`azure.core.credentials.AccessToken` | ||
| :raises: :class:`azure.core.exceptions.ClientAuthenticationError` | ||
| """ | ||
|
|
||
| if self._authorization_code: | ||
| token = self._client.obtain_token_by_authorization_code( | ||
| code=self._authorization_code, redirect_uri=self._redirect_uri, scopes=scopes, **kwargs | ||
| ) | ||
| self._authorization_code = None # auth codes are single-use | ||
| return token | ||
|
|
||
| token = self._client.get_cached_access_token(scopes) or self._redeem_refresh_token(scopes, **kwargs) | ||
| if not token: | ||
| raise ClientAuthenticationError( | ||
| message="No authorization code, cached access token, or refresh token available." | ||
| ) | ||
|
|
||
| return token | ||
|
|
||
| def _redeem_refresh_token(self, scopes, **kwargs): | ||
| # type: (Iterable[str], **Any) -> Optional[AccessToken] | ||
| for refresh_token in self._client.get_cached_refresh_tokens(scopes): | ||
| token = self._client.obtain_token_by_refresh_token(refresh_token, scopes, **kwargs) | ||
| if token: | ||
| return token | ||
| return None |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
30 changes: 30 additions & 0 deletions
30
sdk/identity/azure-identity/azure/identity/_internal/aad_client.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| """A thin wrapper around MSAL's token cache and OAuth 2 client""" | ||
|
|
||
| import time | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from azure.core.credentials import AccessToken | ||
|
|
||
| from .aad_client_base import AadClientBase | ||
| from .msal_transport_adapter import MsalTransportAdapter | ||
| from .exception_wrapper import wrap_exceptions | ||
|
|
||
| if TYPE_CHECKING: | ||
| # pylint:disable=unused-import,ungrouped-imports | ||
| from typing import Any, Callable, Iterable | ||
|
|
||
|
|
||
| class AadClient(AadClientBase): | ||
| def _get_client_session(self, **kwargs): | ||
| return MsalTransportAdapter(**kwargs) | ||
|
|
||
| @wrap_exceptions | ||
| def _obtain_token(self, scopes, fn, **kwargs): # pylint:disable=unused-argument | ||
| # type: (Iterable[str], Callable, **Any) -> AccessToken | ||
| now = int(time.time()) | ||
| response = fn() | ||
| return self._process_response(response=response, scopes=scopes, now=now) |
119 changes: 119 additions & 0 deletions
119
sdk/identity/azure-identity/azure/identity/_internal/aad_client_base.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| import abc | ||
| import functools | ||
| import time | ||
|
|
||
| try: | ||
| from typing import TYPE_CHECKING | ||
| except ImportError: | ||
| TYPE_CHECKING = False | ||
|
|
||
| from msal import TokenCache | ||
| from msal.oauth2cli.oauth2 import Client | ||
|
|
||
| from azure.core.credentials import AccessToken | ||
| from azure.core.exceptions import ClientAuthenticationError | ||
| from .._constants import KnownAuthorities | ||
|
|
||
| try: | ||
| ABC = abc.ABC | ||
| except AttributeError: # Python 2.7, abc exists, but not ABC | ||
| ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore | ||
|
|
||
| if TYPE_CHECKING: | ||
| # pylint:disable=unused-import,ungrouped-imports | ||
| from typing import Any, Callable, Iterable, Optional | ||
|
|
||
|
|
||
| class AadClientBase(ABC): | ||
| """Sans I/O methods for AAD clients wrapping MSAL's OAuth client""" | ||
|
|
||
| def __init__(self, client_id, tenant, **kwargs): | ||
| # type: (str, str, **Any) -> None | ||
| authority = kwargs.pop("authority", KnownAuthorities.AZURE_PUBLIC_CLOUD) | ||
| if authority[-1] == "/": | ||
| authority = authority[:-1] | ||
| token_endpoint = "https://" + "/".join((authority, tenant, "oauth2/v2.0/token")) | ||
| config = {"token_endpoint": token_endpoint} | ||
|
|
||
| self._client = Client(server_configuration=config, client_id=client_id) | ||
| self._client.session.close() | ||
| self._client.session = self._get_client_session(**kwargs) | ||
| self._cache = TokenCache() | ||
|
|
||
| def get_cached_access_token(self, scopes): | ||
| # type: (Iterable[str]) -> Optional[AccessToken] | ||
| tokens = self._cache.find(TokenCache.CredentialType.ACCESS_TOKEN, target=list(scopes)) | ||
| for token in tokens: | ||
| expires_on = int(token["expires_on"]) | ||
| if expires_on - 300 > int(time.time()): | ||
| return AccessToken(token["secret"], expires_on) | ||
| return None | ||
|
|
||
| def get_cached_refresh_tokens(self, scopes): | ||
| """Assumes all cached refresh tokens belong to the same user""" | ||
| return self._cache.find(TokenCache.CredentialType.REFRESH_TOKEN, target=list(scopes)) | ||
|
|
||
| def obtain_token_by_authorization_code(self, code, redirect_uri, scopes, **kwargs): | ||
| # type: (str, str, Iterable[str], **Any) -> AccessToken | ||
| fn = functools.partial( | ||
| self._client.obtain_token_by_authorization_code, code=code, redirect_uri=redirect_uri, **kwargs | ||
| ) | ||
| return self._obtain_token(scopes, fn, **kwargs) | ||
|
|
||
| def obtain_token_by_refresh_token(self, refresh_token, scopes, **kwargs): | ||
| # type: (str, Iterable[str], **Any) -> AccessToken | ||
| fn = functools.partial( | ||
| self._client.obtain_token_by_refresh_token, | ||
| token_item=refresh_token, | ||
| scope=scopes, | ||
| rt_getter=lambda token: token["secret"], | ||
| **kwargs | ||
| ) | ||
| return self._obtain_token(scopes, fn, **kwargs) | ||
|
|
||
| def _process_response(self, response, scopes, now): | ||
| # type: (dict, Iterable[str], int) -> AccessToken | ||
| _raise_for_error(response) | ||
| self._cache.add(event={"response": response, "scope": scopes}, now=now) | ||
| if "expires_on" in response: | ||
| expires_on = int(response["expires_on"]) | ||
| elif "expires_in" in response: | ||
| expires_on = now + int(response["expires_in"]) | ||
| else: | ||
| _scrub_secrets(response) | ||
| raise ClientAuthenticationError( | ||
| message="Unexpected response from Azure Active Directory: {}".format(response) | ||
| ) | ||
| return AccessToken(response["access_token"], expires_on) | ||
|
|
||
| @abc.abstractmethod | ||
| def _get_client_session(self, **kwargs): | ||
| pass | ||
|
|
||
| @abc.abstractmethod | ||
| def _obtain_token(self, scopes, fn, **kwargs): | ||
| # type: (Iterable[str], Callable, **Any) -> AccessToken | ||
| pass | ||
|
|
||
|
|
||
| def _scrub_secrets(response): | ||
| for secret in ("access_token", "refresh_token"): | ||
| if secret in response: | ||
| response[secret] = "***" | ||
|
|
||
|
|
||
| def _raise_for_error(response): | ||
| # type: (dict) -> None | ||
| if "error" not in response: | ||
| return | ||
|
|
||
| _scrub_secrets(response) | ||
| if "error_description" in response: | ||
| message = "Azure Active Directory error '({}) {}'".format(response["error"], response["error_description"]) | ||
| else: | ||
| message = "Azure Active Directory error '{}'".format(response) | ||
| raise ClientAuthenticationError(message=message) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.