Skip to content

Commit 6bade9f

Browse files
authored
Merge pull request #177 from AzureAD/release-1.2.0
Release 1.2.0
2 parents da09f25 + 57236a2 commit 6bade9f

File tree

6 files changed

+142
-20
lines changed

6 files changed

+142
-20
lines changed

docs/conf.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
# add these directories to sys.path here. If the directory is relative to the
1313
# documentation root, use os.path.abspath to make it absolute, like shown here.
1414
#
15-
# import os
16-
# import sys
17-
# sys.path.insert(0, os.path.abspath('.'))
15+
import os
16+
import sys
17+
sys.path.insert(0, os.path.abspath('..'))
1818

1919

2020
# -- Project information -----------------------------------------------------

msal/application.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020

2121
# The __init__.py will import this. Not the other way around.
22-
__version__ = "1.1.0"
22+
__version__ = "1.2.0"
2323

2424
logger = logging.getLogger(__name__)
2525

@@ -229,6 +229,7 @@ def get_authorization_request_url(
229229
redirect_uri=None,
230230
response_type="code", # Can be "token" if you use Implicit Grant
231231
prompt=None,
232+
nonce=None,
232233
**kwargs):
233234
"""Constructs a URL for you to start a Authorization Code Grant.
234235
@@ -247,6 +248,9 @@ def get_authorization_request_url(
247248
You will have to specify a value explicitly.
248249
Its valid values are defined in Open ID Connect specs
249250
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
251+
:param nonce:
252+
A cryptographically random value used to mitigate replay attacks. See also
253+
`OIDC specs <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
250254
:return: The authorization url as a string.
251255
"""
252256
""" # TBD: this would only be meaningful in a new acquire_token_interactive()
@@ -276,6 +280,7 @@ def get_authorization_request_url(
276280
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
277281
prompt=prompt,
278282
scope=decorate_scope(scopes, self.client_id),
283+
nonce=nonce,
279284
)
280285

281286
def acquire_token_by_authorization_code(
@@ -286,6 +291,7 @@ def acquire_token_by_authorization_code(
286291
# REQUIRED, if the "redirect_uri" parameter was included in the
287292
# authorization request as described in Section 4.1.1, and their
288293
# values MUST be identical.
294+
nonce=None,
289295
**kwargs):
290296
"""The second half of the Authorization Code Grant.
291297
@@ -306,6 +312,11 @@ def acquire_token_by_authorization_code(
306312
So the developer need to specify a scope so that we can restrict the
307313
token to be issued for the corresponding audience.
308314
315+
:param nonce:
316+
If you provided a nonce when calling :func:`get_authorization_request_url`,
317+
same nonce should also be provided here, so that we'll validate it.
318+
An exception will be raised if the nonce in id token mismatches.
319+
309320
:return: A dict representing the json response from AAD:
310321
311322
- A successful response would contain "access_token" key,
@@ -326,6 +337,7 @@ def acquire_token_by_authorization_code(
326337
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
327338
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
328339
},
340+
nonce=nonce,
329341
**kwargs)
330342

331343
def get_accounts(self, username=None):
@@ -713,7 +725,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs):
713725

714726
def acquire_token_by_username_password(
715727
self, username, password, scopes, **kwargs):
716-
"""Gets a token for a given resource via user credentails.
728+
"""Gets a token for a given resource via user credentials.
717729
718730
See this page for constraints of Username Password Flow.
719731
https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication

msal/oauth2cli/http.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""This module documents the minimal http behaviors used by this package.
2+
3+
Its interface is influenced by, and similar to a subset of some popular,
4+
real-world http libraries, such as requests, aiohttp and httpx.
5+
"""
6+
7+
8+
class HttpClient(object):
9+
"""This describes a minimal http request interface used by this package."""
10+
11+
def post(self, url, params=None, data=None, headers=None, **kwargs):
12+
"""HTTP post.
13+
14+
params, data and headers MUST accept a dictionary.
15+
It returns an :class:`~Response`-like object.
16+
17+
Note: In its async counterpart, this method would be defined as async.
18+
"""
19+
return Response()
20+
21+
def get(self, url, params=None, headers=None, **kwargs):
22+
"""HTTP get.
23+
24+
params, data and headers MUST accept a dictionary.
25+
It returns an :class:`~Response`-like object.
26+
27+
Note: In its async counterpart, this method would be defined as async.
28+
"""
29+
return Response()
30+
31+
32+
class Response(object):
33+
"""This describes a minimal http response interface used by this package.
34+
35+
:var int status_code:
36+
The status code of this http response.
37+
38+
Our async code path would also accept an alias as "status".
39+
40+
:var string text:
41+
The body of this http response.
42+
43+
Our async code path would also accept an awaitable with the same name.
44+
"""
45+
status_code = 200 # Our async code path would also accept a name as "status"
46+
47+
text = "body as a string" # Our async code path would also accept an awaitable
48+
# We could define a json() method instead of a text property/method,
49+
# but a `text` would be more generic,
50+
# when downstream packages would potentially access some XML endpoints.
51+
52+
def raise_for_status(self):
53+
"""Raise an exception when http response status contains error"""
54+
raise NotImplementedError("Your implementation should provide this")
55+
56+
57+
def _get_status_code(resp):
58+
# RFC defines and some libraries use "status_code", others use "status"
59+
return getattr(resp, "status_code", None) or resp.status
60+

msal/oauth2cli/oidc.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,37 @@ def _obtain_token(self, grant_type, *args, **kwargs):
8585
ret["id_token_claims"] = self.decode_id_token(ret["id_token"])
8686
return ret
8787

88+
def build_auth_request_uri(self, response_type, nonce=None, **kwargs):
89+
"""Generate an authorization uri to be visited by resource owner.
90+
91+
Return value and all other parameters are the same as
92+
:func:`oauth2.Client.build_auth_request_uri`, plus new parameter(s):
93+
94+
:param nonce:
95+
A hard-to-guess string used to mitigate replay attacks. See also
96+
`OIDC specs <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
97+
"""
98+
return super(Client, self).build_auth_request_uri(
99+
response_type, nonce=nonce, **kwargs)
100+
101+
def obtain_token_by_authorization_code(self, code, nonce=None, **kwargs):
102+
"""Get a token via auhtorization code. a.k.a. Authorization Code Grant.
103+
104+
Return value and all other parameters are the same as
105+
:func:`oauth2.Client.obtain_token_by_authorization_code`,
106+
plus new parameter(s):
107+
108+
:param nonce:
109+
If you provided a nonce when calling :func:`build_auth_request_uri`,
110+
same nonce should also be provided here, so that we'll validate it.
111+
An exception will be raised if the nonce in id token mismatches.
112+
"""
113+
result = super(Client, self).obtain_token_by_authorization_code(
114+
code, **kwargs)
115+
nonce_in_id_token = result.get("id_token_claims", {}).get("nonce")
116+
if "id_token_claims" in result and nonce and nonce != nonce_in_id_token:
117+
raise ValueError(
118+
'The nonce in id token ("%s") should match your nonce ("%s")' %
119+
(nonce_in_id_token, nonce))
120+
return result
121+

tests/test_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,11 @@ def test_username_password(self):
132132
def test_auth_code(self):
133133
port = CONFIG.get("listen_port", 44331)
134134
redirect_uri = "http://localhost:%s" % port
135+
nonce = "nonce should contain sufficient entropy"
135136
auth_request_uri = self.client.build_auth_request_uri(
136-
"code", redirect_uri=redirect_uri, scope=CONFIG.get("scope"))
137+
"code",
138+
nonce=nonce,
139+
redirect_uri=redirect_uri, scope=CONFIG.get("scope"))
137140
ac = obtain_auth_code(port, auth_uri=auth_request_uri)
138141
self.assertNotEqual(ac, None)
139142
result = self.client.obtain_token_by_authorization_code(
@@ -142,6 +145,7 @@ def test_auth_code(self):
142145
"scope": CONFIG.get("scope"),
143146
"resource": CONFIG.get("resource"),
144147
}, # MSFT AAD only
148+
nonce=nonce,
145149
redirect_uri=redirect_uri)
146150
self.assertLoosely(result, lambda: self.assertIn('access_token', result))
147151

tests/test_e2e.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ def _get_app_and_auth_code(
1919
authority="https://login.microsoftonline.com/common",
2020
port=44331,
2121
scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph
22-
):
22+
**kwargs):
2323
from msal.oauth2cli.authcode import obtain_auth_code
2424
app = msal.ClientApplication(client_id, client_secret, authority=authority)
2525
redirect_uri = "http://localhost:%d" % port
2626
ac = obtain_auth_code(port, auth_uri=app.get_authorization_request_url(
27-
scopes, redirect_uri=redirect_uri))
27+
scopes, redirect_uri=redirect_uri, **kwargs))
2828
assert ac is not None
2929
return (app, ac, redirect_uri)
3030

@@ -124,20 +124,20 @@ def test_username_password(self):
124124
self.skipUnlessWithConfig(["client_id", "username", "password", "scope"])
125125
self._test_username_password(**self.config)
126126

127-
def _get_app_and_auth_code(self):
127+
def _get_app_and_auth_code(self, **kwargs):
128128
return _get_app_and_auth_code(
129129
self.config["client_id"],
130130
client_secret=self.config.get("client_secret"),
131131
authority=self.config.get("authority"),
132132
port=self.config.get("listen_port", 44331),
133133
scopes=self.config["scope"],
134-
)
134+
**kwargs)
135135

136-
def test_auth_code(self):
136+
def _test_auth_code(self, auth_kwargs, token_kwargs):
137137
self.skipUnlessWithConfig(["client_id", "scope"])
138-
(self.app, ac, redirect_uri) = self._get_app_and_auth_code()
138+
(self.app, ac, redirect_uri) = self._get_app_and_auth_code(**auth_kwargs)
139139
result = self.app.acquire_token_by_authorization_code(
140-
ac, self.config["scope"], redirect_uri=redirect_uri)
140+
ac, self.config["scope"], redirect_uri=redirect_uri, **token_kwargs)
141141
logger.debug("%s.cache = %s",
142142
self.id(), json.dumps(self.app.token_cache._cache, indent=4))
143143
self.assertIn(
@@ -148,6 +148,18 @@ def test_auth_code(self):
148148
error_description=result.get("error_description")))
149149
self.assertCacheWorksForUser(result, self.config["scope"], username=None)
150150

151+
def test_auth_code(self):
152+
self._test_auth_code({}, {})
153+
154+
def test_auth_code_with_matching_nonce(self):
155+
self._test_auth_code({"nonce": "foo"}, {"nonce": "foo"})
156+
157+
def test_auth_code_with_mismatching_nonce(self):
158+
self.skipUnlessWithConfig(["client_id", "scope"])
159+
(self.app, ac, redirect_uri) = self._get_app_and_auth_code(nonce="foo")
160+
with self.assertRaises(ValueError):
161+
self.app.acquire_token_by_authorization_code(
162+
ac, self.config["scope"], redirect_uri=redirect_uri, nonce="bar")
151163

152164
def test_ssh_cert(self):
153165
self.skipUnlessWithConfig(["client_id", "scope"])
@@ -412,22 +424,22 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self):
412424
self.assertCacheWorksForUser(result, scopes, username=None)
413425

414426
@unittest.skipUnless(
415-
os.getenv("OBO_CLIENT_SECRET"),
416-
"Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret")
427+
os.getenv("LAB_OBO_CLIENT_SECRET"),
428+
"Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56")
417429
def test_acquire_token_obo(self):
418430
# Some hardcoded, pre-defined settings
419-
obo_client_id = "23c64cd8-21e4-41dd-9756-ab9e2c23f58c"
420-
downstream_scopes = ["https://graph.microsoft.com/User.Read"]
431+
obo_client_id = "f4aa5217-e87c-42b2-82af-5624dd14ee72"
432+
downstream_scopes = ["https://graph.microsoft.com/.default"]
421433
config = self.get_lab_user(usertype="cloud")
422434

423435
# 1. An app obtains a token representing a user, for our mid-tier service
424436
pca = msal.PublicClientApplication(
425-
"be9b0186-7dfd-448a-a944-f771029105bf", authority=config.get("authority"))
437+
"c0485386-1e9a-4663-bc96-7ab30656de7f", authority=config.get("authority"))
426438
pca_result = pca.acquire_token_by_username_password(
427439
config["username"],
428440
self.get_lab_user_secret(config["lab_name"]),
429441
scopes=[ # The OBO app's scope. Yours might be different.
430-
"%s/access_as_user" % obo_client_id],
442+
"api://%s/read" % obo_client_id],
431443
)
432444
self.assertIsNotNone(
433445
pca_result.get("access_token"),
@@ -436,7 +448,7 @@ def test_acquire_token_obo(self):
436448
# 2. Our mid-tier service uses OBO to obtain a token for downstream service
437449
cca = msal.ConfidentialClientApplication(
438450
obo_client_id,
439-
client_credential=os.getenv("OBO_CLIENT_SECRET"),
451+
client_credential=os.getenv("LAB_OBO_CLIENT_SECRET"),
440452
authority=config.get("authority"),
441453
# token_cache= ..., # Default token cache is all-tokens-store-in-memory.
442454
# That's fine if OBO app uses short-lived msal instance per session.

0 commit comments

Comments
 (0)