Skip to content

Commit 7a7d93b

Browse files
authored
Merge pull request #284 from AzureAD/release-1.7.0
MSAL Python 1.7.0, passed smoke test by Azure SDK team
2 parents 3f1e44b + 1a51030 commit 7a7d93b

File tree

13 files changed

+1103
-95
lines changed

13 files changed

+1103
-95
lines changed

msal/application.py

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222

2323
# The __init__.py will import this. Not the other way around.
24-
__version__ = "1.6.0"
24+
__version__ = "1.7.0"
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -107,6 +107,7 @@ class ClientApplication(object):
107107
ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID = "622"
108108
ACQUIRE_TOKEN_FOR_CLIENT_ID = "730"
109109
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832"
110+
ACQUIRE_TOKEN_INTERACTIVE = "169"
110111
GET_ACCOUNTS_ID = "902"
111112
REMOVE_ACCOUNT_ID = "903"
112113

@@ -300,6 +301,78 @@ def _build_client(self, client_credential, authority):
300301
on_removing_rt=self.token_cache.remove_rt,
301302
on_updating_rt=self.token_cache.update_rt)
302303

304+
def initiate_auth_code_flow(
305+
self,
306+
scopes, # type: list[str]
307+
redirect_uri=None,
308+
state=None, # Recommended by OAuth2 for CSRF protection
309+
prompt=None,
310+
login_hint=None, # type: Optional[str]
311+
domain_hint=None, # type: Optional[str]
312+
claims_challenge=None,
313+
):
314+
"""Initiate an auth code flow.
315+
316+
Later when the response reaches your redirect_uri,
317+
you can use :func:`~acquire_token_by_auth_code_flow()`
318+
to complete the authentication/authorization.
319+
320+
:param list scope:
321+
It is a list of case-sensitive strings.
322+
:param str redirect_uri:
323+
Optional. If not specified, server will use the pre-registered one.
324+
:param str state:
325+
An opaque value used by the client to
326+
maintain state between the request and callback.
327+
If absent, this library will automatically generate one internally.
328+
:param str prompt:
329+
By default, no prompt value will be sent, not even "none".
330+
You will have to specify a value explicitly.
331+
Its valid values are defined in Open ID Connect specs
332+
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
333+
:param str login_hint:
334+
Optional. Identifier of the user. Generally a User Principal Name (UPN).
335+
:param domain_hint:
336+
Can be one of "consumers" or "organizations" or your tenant domain "contoso.com".
337+
If included, it will skip the email-based discovery process that user goes
338+
through on the sign-in page, leading to a slightly more streamlined user experience.
339+
More information on possible values
340+
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
341+
`here <https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8>`_.
342+
343+
:return:
344+
The auth code flow. It is a dict in this form::
345+
346+
{
347+
"auth_uri": "https://...", // Guide user to visit this
348+
"state": "...", // You may choose to verify it by yourself,
349+
// or just let acquire_token_by_auth_code_flow()
350+
// do that for you.
351+
"...": "...", // Everything else are reserved and internal
352+
}
353+
354+
The caller is expected to::
355+
356+
1. somehow store this content, typically inside the current session,
357+
2. guide the end user (i.e. resource owner) to visit that auth_uri,
358+
3. and then relay this dict and subsequent auth response to
359+
:func:`~acquire_token_by_auth_code_flow()`.
360+
"""
361+
client = Client(
362+
{"authorization_endpoint": self.authority.authorization_endpoint},
363+
self.client_id,
364+
http_client=self.http_client)
365+
flow = client.initiate_auth_code_flow(
366+
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
367+
prompt=prompt,
368+
scope=decorate_scope(scopes, self.client_id),
369+
domain_hint=domain_hint,
370+
claims=_merge_claims_challenge_and_capabilities(
371+
self._client_capabilities, claims_challenge),
372+
)
373+
flow["claims_challenge"] = claims_challenge
374+
return flow
375+
303376
def get_authorization_request_url(
304377
self,
305378
scopes, # type: list[str]
@@ -386,6 +459,73 @@ def get_authorization_request_url(
386459
self._client_capabilities, claims_challenge),
387460
)
388461

462+
def acquire_token_by_auth_code_flow(
463+
self, auth_code_flow, auth_response, scopes=None, **kwargs):
464+
"""Validate the auth response being redirected back, and obtain tokens.
465+
466+
It automatically provides nonce protection.
467+
468+
:param dict auth_code_flow:
469+
The same dict returned by :func:`~initiate_auth_code_flow()`.
470+
:param dict auth_response:
471+
A dict of the query string received from auth server.
472+
:param list[str] scopes:
473+
Scopes requested to access a protected API (a resource).
474+
475+
Most of the time, you can leave it empty.
476+
477+
If you requested user consent for multiple resources, here you will
478+
need to provide a subset of what you required in
479+
:func:`~initiate_auth_code_flow()`.
480+
481+
OAuth2 was designed mostly for singleton services,
482+
where tokens are always meant for the same resource and the only
483+
changes are in the scopes.
484+
In AAD, tokens can be issued for multiple 3rd party resources.
485+
You can ask authorization code for multiple resources,
486+
but when you redeem it, the token is for only one intended
487+
recipient, called audience.
488+
So the developer need to specify a scope so that we can restrict the
489+
token to be issued for the corresponding audience.
490+
491+
:return:
492+
* A dict containing "access_token" and/or "id_token", among others,
493+
depends on what scope was used.
494+
(See https://tools.ietf.org/html/rfc6749#section-5.1)
495+
* A dict containing "error", optionally "error_description", "error_uri".
496+
(It is either `this <https://tools.ietf.org/html/rfc6749#section-4.1.2.1>`_
497+
or `that <https://tools.ietf.org/html/rfc6749#section-5.2>`_)
498+
* Most client-side data error would result in ValueError exception.
499+
So the usage pattern could be without any protocol details::
500+
501+
def authorize(): # A controller in a web app
502+
try:
503+
result = msal_app.acquire_token_by_auth_code_flow(
504+
session.get("flow", {}), request.args)
505+
if "error" in result:
506+
return render_template("error.html", result)
507+
use(result) # Token(s) are available in result and cache
508+
except ValueError: # Usually caused by CSRF
509+
pass # Simply ignore them
510+
return redirect(url_for("index"))
511+
"""
512+
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
513+
return self.client.obtain_token_by_auth_code_flow(
514+
auth_code_flow,
515+
auth_response,
516+
scope=decorate_scope(scopes, self.client_id) if scopes else None,
517+
headers={
518+
CLIENT_REQUEST_ID: _get_new_correlation_id(),
519+
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
520+
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
521+
},
522+
data=dict(
523+
kwargs.pop("data", {}),
524+
claims=_merge_claims_challenge_and_capabilities(
525+
self._client_capabilities,
526+
auth_code_flow.pop("claims_challenge", None))),
527+
**kwargs)
528+
389529
def acquire_token_by_authorization_code(
390530
self,
391531
code,
@@ -858,6 +998,80 @@ def __init__(self, client_id, client_credential=None, **kwargs):
858998
super(PublicClientApplication, self).__init__(
859999
client_id, client_credential=None, **kwargs)
8601000

1001+
def acquire_token_interactive(
1002+
self,
1003+
scopes, # type: list[str]
1004+
prompt=None,
1005+
login_hint=None, # type: Optional[str]
1006+
domain_hint=None, # type: Optional[str]
1007+
claims_challenge=None,
1008+
timeout=None,
1009+
port=None,
1010+
**kwargs):
1011+
"""Acquire token interactively i.e. via a local browser.
1012+
1013+
:param list scope:
1014+
It is a list of case-sensitive strings.
1015+
:param str prompt:
1016+
By default, no prompt value will be sent, not even "none".
1017+
You will have to specify a value explicitly.
1018+
Its valid values are defined in Open ID Connect specs
1019+
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
1020+
:param str login_hint:
1021+
Optional. Identifier of the user. Generally a User Principal Name (UPN).
1022+
:param domain_hint:
1023+
Can be one of "consumers" or "organizations" or your tenant domain "contoso.com".
1024+
If included, it will skip the email-based discovery process that user goes
1025+
through on the sign-in page, leading to a slightly more streamlined user experience.
1026+
More information on possible values
1027+
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
1028+
`here <https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8>`_.
1029+
1030+
:param claims_challenge:
1031+
The claims_challenge parameter requests specific claims requested by the resource provider
1032+
in the form of a claims_challenge directive in the www-authenticate header to be
1033+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
1034+
It is a string of a JSON object which contains lists of claims being requested from these locations.
1035+
1036+
:param int timeout:
1037+
This method will block the current thread.
1038+
This parameter specifies the timeout value in seconds.
1039+
Default value ``None`` means wait indefinitely.
1040+
1041+
:param int port:
1042+
The port to be used to listen to an incoming auth response.
1043+
By default we will use a system-allocated port.
1044+
(The rest of the redirect_uri is hard coded as ``http://localhost``.)
1045+
1046+
:return:
1047+
- A dict containing no "error" key,
1048+
and typically contains an "access_token" key,
1049+
if cache lookup succeeded.
1050+
- A dict containing an "error" key, when token refresh failed.
1051+
"""
1052+
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
1053+
claims = _merge_claims_challenge_and_capabilities(
1054+
self._client_capabilities, claims_challenge)
1055+
return self.client.obtain_token_by_browser(
1056+
scope=decorate_scope(scopes, self.client_id) if scopes else None,
1057+
redirect_uri="http://localhost:{port}".format(
1058+
# Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway
1059+
port=port or 0),
1060+
prompt=prompt,
1061+
login_hint=login_hint,
1062+
timeout=timeout,
1063+
auth_params={
1064+
"claims": claims,
1065+
"domain_hint": domain_hint,
1066+
},
1067+
data=dict(kwargs.pop("data", {}), claims=claims),
1068+
headers={
1069+
CLIENT_REQUEST_ID: _get_new_correlation_id(),
1070+
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
1071+
self.ACQUIRE_TOKEN_INTERACTIVE),
1072+
},
1073+
**kwargs)
1074+
8611075
def initiate_device_flow(self, scopes=None, **kwargs):
8621076
"""Initiate a Device Flow instance,
8631077
which will be used in :func:`~acquire_token_by_device_flow`.

msal/mex.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,25 @@
3333
from xml.etree import cElementTree as ET
3434
except ImportError:
3535
from xml.etree import ElementTree as ET
36+
import logging
3637

3738

39+
logger = logging.getLogger(__name__)
40+
3841
def _xpath_of_root(route_to_leaf):
3942
# Construct an xpath suitable to find a root node which has a specified leaf
4043
return '/'.join(route_to_leaf + ['..'] * (len(route_to_leaf)-1))
4144

4245

4346
def send_request(mex_endpoint, http_client, **kwargs):
44-
mex_document = http_client.get(mex_endpoint, **kwargs).text
45-
return Mex(mex_document).get_wstrust_username_password_endpoint()
47+
mex_resp = http_client.get(mex_endpoint, **kwargs)
48+
mex_resp.raise_for_status()
49+
try:
50+
return Mex(mex_resp.text).get_wstrust_username_password_endpoint()
51+
except ET.ParseError:
52+
logger.exception(
53+
"Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text)
54+
raise
4655

4756

4857
class Mex(object):

msal/oauth2cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
__version__ = "0.3.0"
1+
__version__ = "0.4.0"
22

33
from .oidc import Client
44
from .assertion import JwtAssertionCreator
55
from .assertion import JwtSigner # Obsolete. For backward compatibility.
6+
from .authcode import AuthCodeReceiver
67

msal/oauth2cli/assertion.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ def __init__(self, key, algorithm, sha1_thumbprint=None, headers=None):
6363
6464
Args:
6565
66-
key (str): The key for signing, e.g. a base64 encoded private key.
66+
key (str):
67+
An unencrypted private key for signing, in a base64 encoded string.
68+
It can also be a cryptography ``PrivateKey`` object,
69+
which is how you can work with a previously-encrypted key.
70+
See also https://github.com/jpadilla/pyjwt/pull/525
6771
algorithm (str):
6872
"RS256", etc.. See https://pyjwt.readthedocs.io/en/latest/algorithms.html
6973
RSA and ECDSA algorithms require "pip install cryptography".

0 commit comments

Comments
 (0)