-
Notifications
You must be signed in to change notification settings - Fork 1
User authentication API #4
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| import json | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| if TYPE_CHECKING: | ||
| from typing import Any | ||
|
|
||
|
|
||
| class AuthProfile(object): | ||
| """Public user information from an authentication. | ||
|
|
||
| :param str environment: the Azure Active Directory instance which authenticated the user | ||
| :param str home_account_id: the user's Azure Active Directory object ID and home tenant ID | ||
| :param str tenant_id: the tenant which authenticated the user | ||
| :param str username: the user's username (usually an email address) | ||
| """ | ||
|
|
||
| def __init__(self, environment, home_account_id, tenant_id, username, **kwargs): | ||
| # type: (str, str, str, str, **Any) -> None | ||
| self._additional_data = kwargs | ||
| self.environment = environment | ||
| self.home_account_id = home_account_id | ||
| self.tenant_id = tenant_id | ||
| self.username = username | ||
|
|
||
| @property | ||
| def additional_data(self): | ||
| # type: () -> dict | ||
| """A dictionary of extra data deserialized alongside the profile""" | ||
|
|
||
| return dict(self._additional_data) | ||
|
|
||
| def __getitem__(self, key): | ||
| return getattr(self, key, None) or self._additional_data[key] | ||
|
|
||
| @classmethod | ||
| def deserialize(cls, json_string): | ||
| # type: (str) -> AuthProfile | ||
| """Deserialize a profile from JSON""" | ||
|
|
||
| deserialized = json.loads(json_string) | ||
|
|
||
| return cls( | ||
| environment=deserialized.pop("environment"), | ||
| home_account_id=deserialized.pop("home_account_id"), | ||
| tenant_id=deserialized.pop("tenant_id"), | ||
| username=deserialized.pop("username"), | ||
| **deserialized | ||
| ) | ||
|
|
||
| def serialize(self, **kwargs): | ||
| # type: (**Any) -> str | ||
| """Serialize the profile and any keyword arguments to JSON""" | ||
|
|
||
| profile = dict( | ||
| { | ||
| "environment": self.environment, | ||
| "home_account_id": self.home_account_id, | ||
| "tenant_id": self.tenant_id, | ||
| "username": self.username, | ||
| }, | ||
| **kwargs | ||
| ) | ||
|
|
||
| return json.dumps(profile) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,10 @@ | |
| from azure.core.credentials import AccessToken | ||
| from azure.core.exceptions import ClientAuthenticationError | ||
|
|
||
| from .. import CredentialUnavailableError | ||
| from .. import AuthenticationRequiredError, CredentialUnavailableError | ||
| from .._constants import AZURE_CLI_CLIENT_ID | ||
| from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions | ||
| from .._internal import ARM_SCOPE, AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions | ||
| from .._internal.msal_credentials import _build_auth_profile | ||
|
|
||
| try: | ||
| from typing import TYPE_CHECKING | ||
|
|
@@ -21,7 +22,8 @@ | |
|
|
||
| if TYPE_CHECKING: | ||
| # pylint:disable=unused-import | ||
| from typing import Any, List, Mapping | ||
| from typing import Any, List, Mapping, Tuple | ||
| from .. import AuthProfile | ||
|
|
||
|
|
||
| class InteractiveBrowserCredential(PublicClientCredential): | ||
|
|
@@ -38,6 +40,11 @@ class InteractiveBrowserCredential(PublicClientCredential): | |
| authenticate work or school accounts. | ||
| :keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If | ||
| unspecified, the Azure CLI's ID will be used. | ||
| :keyword ~azure.identity.AuthProfile profile: a user profile from a prior authentication. If provided, keyword | ||
| arguments ``authority`` and ``tenant_id`` will be ignored because the profile contains this information. | ||
| :keyword bool silent_auth_only: authenticate only silently (without user interaction). False by default. If True, | ||
| :func:`~get_token` will raise :class:`~azure.identity.AuthenticationRequiredError` when it cannot | ||
| authenticate silently. | ||
| :keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes). | ||
| """ | ||
|
|
||
|
|
@@ -48,7 +55,6 @@ def __init__(self, **kwargs): | |
| client_id = kwargs.pop("client_id", AZURE_CLI_CLIENT_ID) | ||
| super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs) | ||
|
|
||
| @wrap_exceptions | ||
| def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument | ||
| # type: (*str, **Any) -> AccessToken | ||
| """Request an access token for `scopes`. | ||
|
|
@@ -65,26 +71,57 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument | |
| :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` | ||
| attribute gives a reason. Any error response from Azure Active Directory is available as the error's | ||
| ``response`` attribute. | ||
| :raises ~azure.identity.AuthenticationRequiredError: the credential is configured to authenticate only silently | ||
| (without user interaction), and was unable to do so. | ||
| """ | ||
| if not scopes: | ||
| raise ValueError("'get_token' requires at least one scope") | ||
|
|
||
| return self._get_token_from_cache(scopes, **kwargs) or self._get_token_by_auth_code(scopes, **kwargs) | ||
| token = self._acquire_token_silent(*scopes, **kwargs) | ||
| if not token: | ||
| if self._silent_auth_only: | ||
| raise AuthenticationRequiredError() | ||
|
|
||
| def _get_token_from_cache(self, scopes, **kwargs): | ||
| """if the user has already signed in, we can redeem a refresh token for a new access token""" | ||
| app = self._get_app() | ||
| accounts = app.get_accounts() | ||
| if accounts: # => user has already authenticated | ||
| # MSAL asserts scopes is a list | ||
| scopes = list(scopes) # type: ignore | ||
| now = int(time.time()) | ||
| token = app.acquire_token_silent(scopes, account=accounts[0], **kwargs) | ||
| if token and "access_token" in token and "expires_in" in token: | ||
| return AccessToken(token["access_token"], now + int(token["expires_in"])) | ||
| return None | ||
| response = self._get_token_by_auth_code(*scopes, **kwargs) | ||
|
|
||
| # update profile because the user may have authenticated a different identity | ||
| self._profile = _build_auth_profile(response) | ||
|
|
||
| token = AccessToken(response["access_token"], now + int(response["expires_in"])) | ||
|
|
||
| return token | ||
|
|
||
| @classmethod | ||
| def authenticate(cls, client_id, **kwargs): | ||
|
||
| # type: (str, **Any) -> Tuple[InteractiveBrowserCredential, AuthProfile] | ||
| """Authenticate a user. Returns a credential ready to get tokens for that user, and a user profile. | ||
|
|
||
| This method will open a browser to a login page and listen on localhost for a request indicating authentication | ||
| has completed. | ||
|
|
||
| Accepts the same keyword arguments as :class:`~InteractiveBrowserCredential` | ||
|
|
||
| :param str client_id: Client ID of the Azure Active Directory application the user will sign in to | ||
| :rtype: ~azure.identity.InteractiveBrowserCredential, ~azure.identity.AuthProfile | ||
| :raises ~azure.identity.CredentialUnavailableError: the credential is unable to start an HTTP server on | ||
| localhost, or is unable to open a browser | ||
| :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` | ||
| attribute gives a reason. Any error response from Azure Active Directory is available as the error's | ||
| ``response`` attribute. | ||
| """ | ||
| # pylint:disable=protected-access | ||
| scope = kwargs.pop("scope", None) or ARM_SCOPE | ||
|
|
||
| credential = cls(client_id=client_id, **kwargs) | ||
| response = credential._get_token_by_auth_code(scope) | ||
| profile = _build_auth_profile(response) | ||
| credential._profile = profile | ||
|
|
||
| def _get_token_by_auth_code(self, scopes, **kwargs): | ||
| return credential, profile | ||
|
|
||
| @wrap_exceptions | ||
| def _get_token_by_auth_code(self, *scopes, **kwargs): | ||
| # start an HTTP server on localhost to receive the redirect | ||
| for port in range(8400, 9000): | ||
| try: | ||
|
|
@@ -118,13 +155,12 @@ def _get_token_by_auth_code(self, scopes, **kwargs): | |
|
|
||
| # redeem the authorization code for a token | ||
| code = self._parse_response(request_state, response) | ||
| now = int(time.time()) | ||
| result = app.acquire_token_by_authorization_code(code, scopes=scopes, redirect_uri=redirect_uri, **kwargs) | ||
|
|
||
| if "access_token" not in result: | ||
| raise ClientAuthenticationError(message="Authentication failed: {}".format(result.get("error_description"))) | ||
|
|
||
| return AccessToken(result["access_token"], now + int(result["expires_in"])) | ||
| return result | ||
|
|
||
| @staticmethod | ||
| def _parse_response(request_state, response): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please add the MSAL error result in the error exception so that client side can get the fail reason when get the access token