-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Synchronous interactive browser authentication (#6466)
- Loading branch information
Showing
5 changed files
with
270 additions
and
2 deletions.
There are no files selected for viewing
This file contains 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 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/_internal/auth_code_redirect_handler.py
This file contains 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. | ||
# ------------------------------------ | ||
try: | ||
from typing import TYPE_CHECKING | ||
except ImportError: | ||
TYPE_CHECKING = False | ||
|
||
if TYPE_CHECKING: | ||
from typing import Any, Mapping, Optional | ||
|
||
try: | ||
from http.server import HTTPServer, BaseHTTPRequestHandler | ||
except ImportError: | ||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler # type: ignore | ||
|
||
try: | ||
from urllib.parse import parse_qs | ||
except ImportError: | ||
from urlparse import parse_qs # type: ignore | ||
|
||
|
||
class AuthCodeRedirectHandler(BaseHTTPRequestHandler): | ||
"""HTTP request handler to capture the authentication server's response. | ||
Largely from the Azure CLI: https://github.com/Azure/azure-cli/blob/dev/src/azure-cli-core/azure/cli/core/_profile.py | ||
""" | ||
|
||
def do_GET(self): | ||
if self.path.endswith("/favicon.ico"): # deal with legacy IE | ||
self.send_response(204) | ||
return | ||
|
||
query = self.path.split("?", 1)[-1] | ||
query = parse_qs(query, keep_blank_values=True) | ||
self.server.query_params = query | ||
|
||
self.send_response(200) | ||
self.send_header("Content-Type", "text/html") | ||
self.end_headers() | ||
|
||
self.wfile.write(b"Authentication complete. You can close this window.") | ||
|
||
def log_message(self, format, *args): # pylint: disable=redefined-builtin,unused-argument,no-self-use | ||
pass # this prevents server dumping messages to stdout | ||
|
||
|
||
class AuthCodeRedirectServer(HTTPServer): | ||
"""HTTP server that listens on localhost for the redirect request following an authorization code authentication""" | ||
|
||
query_params = {} # type: Mapping[str, Any] | ||
|
||
def __init__(self, port, timeout): | ||
# type: (int, int) -> None | ||
super(AuthCodeRedirectServer, self).__init__(("localhost", port), AuthCodeRedirectHandler) | ||
self.timeout = timeout | ||
|
||
def wait_for_redirect(self): | ||
# type: () -> Mapping[str, Any] | ||
while not self.query_params: | ||
try: | ||
self.handle_request() | ||
except ValueError: | ||
# socket has been closed, probably by handle_timeout | ||
break | ||
|
||
# ensure the underlying socket is closed (a no-op when the socket is already closed) | ||
self.server_close() | ||
|
||
# if we timed out, this returns an empty dict | ||
return self.query_params | ||
|
||
def handle_timeout(self): | ||
"""Break the request-handling loop by tearing down the server""" | ||
self.server_close() |
121 changes: 121 additions & 0 deletions
121
sdk/identity/azure-identity/azure/identity/browser_auth.py
This file contains 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,121 @@ | ||
# ------------------------------------ | ||
# Copyright (c) Microsoft Corporation. | ||
# Licensed under the MIT License. | ||
# ------------------------------------ | ||
import socket | ||
import time | ||
import uuid | ||
import webbrowser | ||
|
||
try: | ||
from typing import TYPE_CHECKING | ||
except ImportError: | ||
TYPE_CHECKING = False | ||
|
||
if TYPE_CHECKING: | ||
from typing import Any, List, Mapping | ||
|
||
from azure.core.credentials import AccessToken | ||
from azure.core.exceptions import ClientAuthenticationError | ||
|
||
from ._internal import AuthCodeRedirectServer, ConfidentialClientCredential | ||
|
||
|
||
class InteractiveBrowserCredential(ConfidentialClientCredential): | ||
""" | ||
Authenticates a user through the authorization code flow. This is an interactive flow: ``get_token`` opens a | ||
browser to a login URL provided by Azure Active Directory, and waits for the user to authenticate there. | ||
Azure Active Directory documentation describes the authorization code flow in more detail: | ||
https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-protocols-oauth-code | ||
:param str client_id: the application's client ID | ||
:param str secret: one of the application's client secrets | ||
**Keyword arguments:** | ||
*tenant (str)* - a tenant ID or a domain associated with a tenant. If not provided, the credential defaults to the | ||
'organizations' tenant, which can authenticate work or school accounts. | ||
*timeout (str)* - seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes). | ||
""" | ||
|
||
def __init__(self, client_id, client_secret, **kwargs): | ||
# type: (str, str, Any) -> None | ||
self._timeout = kwargs.pop("timeout", 300) | ||
self._server_class = kwargs.pop("server_class", AuthCodeRedirectServer) # facilitate mocking | ||
authority = "https://login.microsoftonline.com/" + kwargs.pop("tenant", "organizations") | ||
super(InteractiveBrowserCredential, self).__init__( | ||
client_id=client_id, client_credential=client_secret, authority=authority, **kwargs | ||
) | ||
|
||
def get_token(self, *scopes): | ||
# type: (str) -> AccessToken | ||
""" | ||
Request an access token for `scopes`. This will open a browser to a login page and listen on localhost for a | ||
request indicating authentication has completed. | ||
:param str scopes: desired scopes for the token | ||
:rtype: :class:`azure.core.credentials.AccessToken` | ||
:raises: :class:`azure.core.exceptions.ClientAuthenticationError` | ||
""" | ||
|
||
# start an HTTP server on localhost to receive the redirect | ||
for port in range(8400, 9000): | ||
try: | ||
server = self._server_class(port, timeout=self._timeout) | ||
redirect_uri = "http://localhost:{}".format(port) | ||
break | ||
except socket.error: | ||
continue # keep looking for an open port | ||
|
||
if not redirect_uri: | ||
raise ClientAuthenticationError(message="Couldn't start an HTTP server on localhost") | ||
|
||
# get the url the user must visit to authenticate | ||
scopes = list(scopes) # type: ignore | ||
request_state = str(uuid.uuid4()) | ||
app = self._get_app() | ||
auth_url = app.get_authorization_request_url(scopes, redirect_uri=redirect_uri, state=request_state) | ||
|
||
# open browser to that url | ||
webbrowser.open(auth_url) | ||
|
||
# block until the server times out or receives the post-authentication redirect | ||
response = server.wait_for_redirect() | ||
if not response: | ||
raise ClientAuthenticationError( | ||
message="Timed out after waiting {} seconds for the user to authenticate".format(self._timeout) | ||
) | ||
|
||
# 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) | ||
|
||
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"])) | ||
|
||
def _parse_response(self, request_state, response): | ||
# type: (str, Mapping[str, Any]) -> List[str] | ||
""" | ||
Validates ``response`` and returns the authorization code it contains, if authentication succeeded. Raises | ||
:class:`azure.core.exceptions.ClientAuthenticationError`, if authentication failed or ``response`` is malformed. | ||
""" | ||
|
||
if "error" in response: | ||
message = "Authentication failed: {}".format(response.get("error_description") or response["error"]) | ||
raise ClientAuthenticationError(message=message) | ||
if "code" not in response: | ||
# a response with no error or code is malformed; we don't know what to do with it | ||
message = "Authentication server didn't send an authorization code" | ||
raise ClientAuthenticationError(message=message) | ||
|
||
# response must include the state sent in the auth request | ||
if "state" not in response: | ||
raise ClientAuthenticationError(message="Authentication response doesn't include OAuth state") | ||
if response["state"][0] != request_state: | ||
raise ClientAuthenticationError(message="Authentication response's OAuth state doesn't match the request's") | ||
|
||
return response["code"] |
This file contains 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