Skip to content

MSAL Python 1.7.0 #284

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 40 commits into from
Dec 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0268635
Merge pull request #273 from AzureAD/release-1.6.0
rayluo Nov 2, 2020
1846f91
Document how to work with an encrypted private key
rayluo Nov 2, 2020
e6be9ec
authcode.py CLI has long been broken. Now fixed.
rayluo Oct 27, 2020
7042ff5
Rewrite authcode.py to support dynamic port
rayluo Oct 27, 2020
73a444c
AuthCodeReceiver supports 3 templates
rayluo Nov 13, 2020
b53684c
Merge branch 'improve-authcode-receiver' into dev
rayluo Nov 13, 2020
9c3b49e
Merge remote-tracking branch 'oauth2cli/dev' into dev
rayluo Nov 13, 2020
e28bec1
Forgot to remove abandoned "text" parameter
rayluo Nov 17, 2020
df27e67
Merge branch 'improve-authcode-receiver' into dev
rayluo Nov 17, 2020
0cb8a8f
initiate_auth_code_flow() and obtain_token_by_auth_code_flow()
rayluo May 12, 2020
e8be841
End-to-end test case
rayluo Oct 28, 2020
8768915
Merge branch 'obtain-token-by-auth-code-flow' into dev
rayluo Nov 17, 2020
8ea9ba5
Merge remote-tracking branch 'oauth2cli/dev' into auth-code-flow
rayluo Nov 17, 2020
f044306
Add sanity checks to allow simpler usage pattern
rayluo Nov 19, 2020
0e512b1
Merge branch 'obtain-token-by-auth-code-flow' into dev
rayluo Nov 19, 2020
dfbbc66
Merge remote-tracking branch 'oauth2cli/dev' into acf
rayluo Nov 19, 2020
2915ab8
PKCE in obtain_token_by_auth_code_flow()
rayluo Nov 11, 2020
decbedc
Merge branch 'pkce-in-auth-code-flow' into dev
rayluo Nov 25, 2020
0b4c157
Merge remote-tracking branch 'oauth2cli/dev' into pkce-via-auth-code-…
rayluo Nov 25, 2020
06c2b65
Implement acquire_token_by_auth_code_flow
rayluo Nov 12, 2020
0845404
Reuse old rt data even if its key is different
rayluo Nov 25, 2020
3a91806
Merge pull request #280 from AzureAD/bugfix-handle-rt-with-different-key
rayluo Nov 25, 2020
6613fd0
Merge pull request #276 from AzureAD/acquire_token_by_auth_code_flow
rayluo Nov 26, 2020
03be495
Implement obtain_token_by_browser()
rayluo Nov 3, 2020
deaad95
Merge branch 'obtain-token-by-browser' into dev
rayluo Dec 2, 2020
2fb7a45
Self-audit: our usage pattern is not vulnerable to CVE-2020-26244
rayluo Dec 1, 2020
97c2585
Merge branch 'audit' into dev
rayluo Dec 2, 2020
6463b47
MEX endpoint in our test environment tends to fail recently
rayluo Dec 2, 2020
4404525
Merge branch 'ignore-httperror-for-one-testcase' into dev
rayluo Dec 3, 2020
49ce6c5
Merge remote-tracking branch 'oauth2cli/dev' into dev
rayluo Dec 3, 2020
de66698
New acquire_token_interactive()
rayluo Nov 24, 2020
4cfbba7
Merge pull request #260 from AzureAD/acquire-token-interactive
rayluo Dec 3, 2020
b906afa
Improve test infrastructure to catch a malfunction
rayluo Dec 4, 2020
1c53d0f
Override the default log-every-request-to-stderr
rayluo Dec 4, 2020
0642f14
Merge branch 'auth-code-flow-log' into dev
rayluo Dec 4, 2020
7af214b
Merge pull request #282 from AzureAD/acquire-token-interactive-bugfix
rayluo Dec 4, 2020
84c7349
Merge remote-tracking branch 'oauth2cli/dev' into dev
rayluo Dec 4, 2020
dd1636d
A sample for the new acquire_token_interactive()
rayluo Dec 4, 2020
daa6478
Merge pull request #283 from AzureAD/acquire-token-interactive-sample
rayluo Dec 5, 2020
1a51030
MSAL Python 1.7.0
rayluo Dec 5, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 215 additions & 1 deletion msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@


# The __init__.py will import this. Not the other way around.
__version__ = "1.6.0"
__version__ = "1.7.0"

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -107,6 +107,7 @@ class ClientApplication(object):
ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID = "622"
ACQUIRE_TOKEN_FOR_CLIENT_ID = "730"
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832"
ACQUIRE_TOKEN_INTERACTIVE = "169"
GET_ACCOUNTS_ID = "902"
REMOVE_ACCOUNT_ID = "903"

Expand Down Expand Up @@ -300,6 +301,78 @@ def _build_client(self, client_credential, authority):
on_removing_rt=self.token_cache.remove_rt,
on_updating_rt=self.token_cache.update_rt)

def initiate_auth_code_flow(
self,
scopes, # type: list[str]
redirect_uri=None,
state=None, # Recommended by OAuth2 for CSRF protection
prompt=None,
login_hint=None, # type: Optional[str]
domain_hint=None, # type: Optional[str]
claims_challenge=None,
):
"""Initiate an auth code flow.

Later when the response reaches your redirect_uri,
you can use :func:`~acquire_token_by_auth_code_flow()`
to complete the authentication/authorization.

:param list scope:
It is a list of case-sensitive strings.
:param str redirect_uri:
Optional. If not specified, server will use the pre-registered one.
:param str state:
An opaque value used by the client to
maintain state between the request and callback.
If absent, this library will automatically generate one internally.
:param str prompt:
By default, no prompt value will be sent, not even "none".
You will have to specify a value explicitly.
Its valid values are defined in Open ID Connect specs
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
:param str login_hint:
Optional. Identifier of the user. Generally a User Principal Name (UPN).
:param domain_hint:
Can be one of "consumers" or "organizations" or your tenant domain "contoso.com".
If included, it will skip the email-based discovery process that user goes
through on the sign-in page, leading to a slightly more streamlined user experience.
More information on possible values
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
`here <https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8>`_.

:return:
The auth code flow. It is a dict in this form::

{
"auth_uri": "https://...", // Guide user to visit this
"state": "...", // You may choose to verify it by yourself,
// or just let acquire_token_by_auth_code_flow()
// do that for you.
"...": "...", // Everything else are reserved and internal
}

The caller is expected to::

1. somehow store this content, typically inside the current session,
2. guide the end user (i.e. resource owner) to visit that auth_uri,
3. and then relay this dict and subsequent auth response to
:func:`~acquire_token_by_auth_code_flow()`.
"""
client = Client(
{"authorization_endpoint": self.authority.authorization_endpoint},
self.client_id,
http_client=self.http_client)
flow = client.initiate_auth_code_flow(
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
prompt=prompt,
scope=decorate_scope(scopes, self.client_id),
domain_hint=domain_hint,
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge),
)
flow["claims_challenge"] = claims_challenge
return flow

def get_authorization_request_url(
self,
scopes, # type: list[str]
Expand Down Expand Up @@ -386,6 +459,73 @@ def get_authorization_request_url(
self._client_capabilities, claims_challenge),
)

def acquire_token_by_auth_code_flow(
self, auth_code_flow, auth_response, scopes=None, **kwargs):
"""Validate the auth response being redirected back, and obtain tokens.

It automatically provides nonce protection.

:param dict auth_code_flow:
The same dict returned by :func:`~initiate_auth_code_flow()`.
:param dict auth_response:
A dict of the query string received from auth server.
:param list[str] scopes:
Scopes requested to access a protected API (a resource).

Most of the time, you can leave it empty.

If you requested user consent for multiple resources, here you will
need to provide a subset of what you required in
:func:`~initiate_auth_code_flow()`.

OAuth2 was designed mostly for singleton services,
where tokens are always meant for the same resource and the only
changes are in the scopes.
In AAD, tokens can be issued for multiple 3rd party resources.
You can ask authorization code for multiple resources,
but when you redeem it, the token is for only one intended
recipient, called audience.
So the developer need to specify a scope so that we can restrict the
token to be issued for the corresponding audience.

:return:
* A dict containing "access_token" and/or "id_token", among others,
depends on what scope was used.
(See https://tools.ietf.org/html/rfc6749#section-5.1)
* A dict containing "error", optionally "error_description", "error_uri".
(It is either `this <https://tools.ietf.org/html/rfc6749#section-4.1.2.1>`_
or `that <https://tools.ietf.org/html/rfc6749#section-5.2>`_)
* Most client-side data error would result in ValueError exception.
So the usage pattern could be without any protocol details::

def authorize(): # A controller in a web app
try:
result = msal_app.acquire_token_by_auth_code_flow(
session.get("flow", {}), request.args)
if "error" in result:
return render_template("error.html", result)
use(result) # Token(s) are available in result and cache
except ValueError: # Usually caused by CSRF
pass # Simply ignore them
return redirect(url_for("index"))
"""
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
return self.client.obtain_token_by_auth_code_flow(
auth_code_flow,
auth_response,
scope=decorate_scope(scopes, self.client_id) if scopes else None,
headers={
CLIENT_REQUEST_ID: _get_new_correlation_id(),
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
},
data=dict(
kwargs.pop("data", {}),
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities,
auth_code_flow.pop("claims_challenge", None))),
**kwargs)

def acquire_token_by_authorization_code(
self,
code,
Expand Down Expand Up @@ -858,6 +998,80 @@ def __init__(self, client_id, client_credential=None, **kwargs):
super(PublicClientApplication, self).__init__(
client_id, client_credential=None, **kwargs)

def acquire_token_interactive(
self,
scopes, # type: list[str]
prompt=None,
login_hint=None, # type: Optional[str]
domain_hint=None, # type: Optional[str]
claims_challenge=None,
timeout=None,
port=None,
**kwargs):
"""Acquire token interactively i.e. via a local browser.

:param list scope:
It is a list of case-sensitive strings.
:param str prompt:
By default, no prompt value will be sent, not even "none".
You will have to specify a value explicitly.
Its valid values are defined in Open ID Connect specs
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
:param str login_hint:
Optional. Identifier of the user. Generally a User Principal Name (UPN).
:param domain_hint:
Can be one of "consumers" or "organizations" or your tenant domain "contoso.com".
If included, it will skip the email-based discovery process that user goes
through on the sign-in page, leading to a slightly more streamlined user experience.
More information on possible values
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
`here <https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8>`_.

:param claims_challenge:
The claims_challenge parameter requests specific claims requested by the resource provider
in the form of a claims_challenge directive in the www-authenticate header to be
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
It is a string of a JSON object which contains lists of claims being requested from these locations.

:param int timeout:
This method will block the current thread.
This parameter specifies the timeout value in seconds.
Default value ``None`` means wait indefinitely.

:param int port:
The port to be used to listen to an incoming auth response.
By default we will use a system-allocated port.
(The rest of the redirect_uri is hard coded as ``http://localhost``.)

:return:
- A dict containing no "error" key,
and typically contains an "access_token" key,
if cache lookup succeeded.
- A dict containing an "error" key, when token refresh failed.
"""
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
claims = _merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge)
return self.client.obtain_token_by_browser(
scope=decorate_scope(scopes, self.client_id) if scopes else None,
redirect_uri="http://localhost:{port}".format(
# Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway
port=port or 0),
prompt=prompt,
login_hint=login_hint,
timeout=timeout,
auth_params={
"claims": claims,
"domain_hint": domain_hint,
},
data=dict(kwargs.pop("data", {}), claims=claims),
headers={
CLIENT_REQUEST_ID: _get_new_correlation_id(),
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
self.ACQUIRE_TOKEN_INTERACTIVE),
},
**kwargs)

def initiate_device_flow(self, scopes=None, **kwargs):
"""Initiate a Device Flow instance,
which will be used in :func:`~acquire_token_by_device_flow`.
Expand Down
13 changes: 11 additions & 2 deletions msal/mex.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,25 @@
from xml.etree import cElementTree as ET
except ImportError:
from xml.etree import ElementTree as ET
import logging


logger = logging.getLogger(__name__)

def _xpath_of_root(route_to_leaf):
# Construct an xpath suitable to find a root node which has a specified leaf
return '/'.join(route_to_leaf + ['..'] * (len(route_to_leaf)-1))


def send_request(mex_endpoint, http_client, **kwargs):
mex_document = http_client.get(mex_endpoint, **kwargs).text
return Mex(mex_document).get_wstrust_username_password_endpoint()
mex_resp = http_client.get(mex_endpoint, **kwargs)
mex_resp.raise_for_status()
try:
return Mex(mex_resp.text).get_wstrust_username_password_endpoint()
except ET.ParseError:
logger.exception(
"Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text)
raise


class Mex(object):
Expand Down
3 changes: 2 additions & 1 deletion msal/oauth2cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
__version__ = "0.3.0"
__version__ = "0.4.0"

from .oidc import Client
from .assertion import JwtAssertionCreator
from .assertion import JwtSigner # Obsolete. For backward compatibility.
from .authcode import AuthCodeReceiver

6 changes: 5 additions & 1 deletion msal/oauth2cli/assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ def __init__(self, key, algorithm, sha1_thumbprint=None, headers=None):

Args:

key (str): The key for signing, e.g. a base64 encoded private key.
key (str):
An unencrypted private key for signing, in a base64 encoded string.
It can also be a cryptography ``PrivateKey`` object,
which is how you can work with a previously-encrypted key.
See also https://github.com/jpadilla/pyjwt/pull/525
algorithm (str):
"RS256", etc.. See https://pyjwt.readthedocs.io/en/latest/algorithms.html
RSA and ECDSA algorithms require "pip install cryptography".
Expand Down
Loading