Skip to content

Commit 6613fd0

Browse files
authored
Merge pull request #276 from AzureAD/acquire_token_by_auth_code_flow
New initialize_auth_code_flow() and acquire_token_by_auth_code_flow()
2 parents 3a91806 + 06c2b65 commit 6613fd0

File tree

4 files changed

+239
-6
lines changed

4 files changed

+239
-6
lines changed

msal/application.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,79 @@ def _build_client(self, client_credential, authority):
300300
on_removing_rt=self.token_cache.remove_rt,
301301
on_updating_rt=self.token_cache.update_rt)
302302

303+
def initiate_auth_code_flow(
304+
self,
305+
scopes, # type: list[str]
306+
redirect_uri=None,
307+
state=None, # Recommended by OAuth2 for CSRF protection
308+
prompt=None,
309+
login_hint=None, # type: Optional[str]
310+
domain_hint=None, # type: Optional[str]
311+
claims_challenge=None,
312+
):
313+
"""Initiate an auth code flow.
314+
315+
Later when the response reaches your redirect_uri,
316+
you can use :func:`~acquire_token_by_auth_code_flow()`
317+
to complete the authentication/authorization.
318+
319+
:param list scope:
320+
It is a list of case-sensitive strings.
321+
Some ID provider can accept empty string to represent default scope.
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,

msal/oauth2cli/oauth2.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import functools
1616
import random
1717
import string
18+
import hashlib
1819

1920
import requests
2021

@@ -258,6 +259,21 @@ def _stringify(self, sequence):
258259
return sequence # as-is
259260

260261

262+
def _generate_pkce_code_verifier(length=43):
263+
assert 43 <= length <= 128
264+
verifier = "".join( # https://tools.ietf.org/html/rfc7636#section-4.1
265+
random.sample(string.ascii_letters + string.digits + "-._~", length))
266+
code_challenge = (
267+
# https://tools.ietf.org/html/rfc7636#section-4.2
268+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest())
269+
.rstrip(b"=")) # Required by https://tools.ietf.org/html/rfc7636#section-3
270+
return {
271+
"code_verifier": verifier,
272+
"transformation": "S256", # In Python, sha256 is always available
273+
"code_challenge": code_challenge,
274+
}
275+
276+
261277
class Client(BaseClient): # We choose to implement all 4 grants in 1 class
262278
"""This is the main API for oauth2 client.
263279
@@ -401,6 +417,8 @@ def initiate_auth_code_flow(
401417
you can use :func:`~obtain_token_by_auth_code_flow()`
402418
to complete the authentication/authorization.
403419
420+
This method also provides PKCE protection automatically.
421+
404422
:param list scope:
405423
It is a list of case-sensitive strings.
406424
Some ID provider can accept empty string to represent default scope.
@@ -440,14 +458,19 @@ def initiate_auth_code_flow(
440458
# Implicit grant would cause auth response coming back in #fragment,
441459
# but fragment won't reach a web service.
442460
raise ValueError('response_type="token ..." is not allowed')
461+
pkce = _generate_pkce_code_verifier()
443462
flow = { # These data are required by obtain_token_by_auth_code_flow()
444463
"state": state or "".join(random.sample(string.ascii_letters, 16)),
445464
"redirect_uri": redirect_uri,
446465
"scope": scope,
447466
}
448467
auth_uri = self._build_auth_request_uri(
449-
response_type, **dict(flow, **kwargs))
468+
response_type,
469+
code_challenge=pkce["code_challenge"],
470+
code_challenge_method=pkce["transformation"],
471+
**dict(flow, **kwargs))
450472
flow["auth_uri"] = auth_uri
473+
flow["code_verifier"] = pkce["code_verifier"]
451474
return flow
452475

453476
def obtain_token_by_auth_code_flow(
@@ -459,6 +482,8 @@ def obtain_token_by_auth_code_flow(
459482
"""With the auth_response being redirected back,
460483
validate it against auth_code_flow, and then obtain tokens.
461484
485+
Internally, it implements PKCE to mitigate the auth code interception attack.
486+
462487
:param dict auth_code_flow:
463488
The same dict returned by :func:`~initiate_auth_code_flow()`.
464489
:param dict auth_response:
@@ -513,6 +538,10 @@ def authorize(): # A controller in a web app
513538
# It is both unnecessary and harmless, per RFC 6749.
514539
# We use the same scope already used in auth request uri,
515540
# thus token cache can know what scope the tokens are for.
541+
data=dict( # Extract and update the data
542+
kwargs.pop("data", {}),
543+
code_verifier=auth_code_flow["code_verifier"],
544+
),
516545
**kwargs)
517546
if auth_response.get("error"): # It means the first leg encountered error
518547
# Here we do NOT return original auth_response as-is, to prevent a

msal/oauth2cli/oidc.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,11 @@ def initiate_auth_code_flow(
173173
return flow
174174

175175
def obtain_token_by_auth_code_flow(self, auth_code_flow, auth_response, **kwargs):
176-
"""Validate the auth_response being redirected back, and then obtain tokens.
177-
and obtain ID token which can be used for user sign in.
176+
"""Validate the auth_response being redirected back, and then obtain tokens,
177+
including ID token which can be used for user sign in.
178178
179-
It provides nonce protection out-of-the-box.
179+
Internally, it implements nonce to mitigate replay attack.
180+
It also implements PKCE to mitigate the auth code interception attack.
180181
181182
See :func:`oauth2.Client.obtain_token_by_auth_code_flow` in parent class
182183
for descriptions on other parameters and return value.

tests/test_e2e.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import msal
1010
from tests.http_client import MinimalHttpClient
11+
from msal.oauth2cli import AuthCodeReceiver
1112

1213
logger = logging.getLogger(__name__)
1314
logging.basicConfig(level=logging.INFO)
@@ -297,14 +298,16 @@ def get_lab_app(
297298
298299
Get it from environment variables if defined, otherwise fall back to use MSI.
299300
"""
301+
logger.info(
302+
"Reading ENV variables %s and %s for lab app defined at "
303+
"https://docs.msidlab.com/accounts/confidentialclient.html",
304+
env_client_id, env_client_secret)
300305
if os.getenv(env_client_id) and os.getenv(env_client_secret):
301306
# A shortcut mainly for running tests on developer's local development machine
302307
# or it could be setup on Travis CI
303308
# https://docs.travis-ci.com/user/environment-variables/#defining-variables-in-repository-settings
304309
# Data came from here
305310
# https://docs.msidlab.com/accounts/confidentialclient.html
306-
logger.info("Using lab app defined by ENV variables %s and %s",
307-
env_client_id, env_client_secret)
308311
client_id = os.getenv(env_client_id)
309312
client_secret = os.getenv(env_client_secret)
310313
else:
@@ -399,6 +402,45 @@ def _test_acquire_token_by_auth_code(
399402
error_description=result.get("error_description")))
400403
self.assertCacheWorksForUser(result, scope, username=None)
401404

405+
def _test_acquire_token_by_auth_code_flow(
406+
self, client_id=None, authority=None, port=None, scope=None,
407+
username_uri="", # But you would want to provide one
408+
**ignored):
409+
assert client_id and authority and scope
410+
self.app = msal.ClientApplication(
411+
client_id, authority=authority, http_client=MinimalHttpClient())
412+
with AuthCodeReceiver(port=port) as receiver:
413+
flow = self.app.initiate_auth_code_flow(
414+
redirect_uri="http://localhost:%d" % receiver.get_port(),
415+
scopes=scope,
416+
)
417+
auth_response = receiver.get_auth_response(
418+
auth_uri=flow["auth_uri"], state=flow["state"], timeout=60,
419+
welcome_template="""<html><body><h1>{id}</h1><ol>
420+
<li>Get a username from the upn shown at <a href="{username_uri}">here</a></li>
421+
<li>Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ
422+
(replace the lab name with the labName from the link above).</li>
423+
<li><a href="$auth_uri">Sign In</a> or <a href="$abort_uri">Abort</a></li>
424+
</ol></body></html>""".format(id=self.id(), username_uri=username_uri),
425+
)
426+
self.assertIsNotNone(
427+
auth_response.get("code"), "Error: {}, Detail: {}".format(
428+
auth_response.get("error"), auth_response))
429+
result = self.app.acquire_token_by_auth_code_flow(flow, auth_response)
430+
logger.debug(
431+
"%s: cache = %s, id_token_claims = %s",
432+
self.id(),
433+
json.dumps(self.app.token_cache._cache, indent=4),
434+
json.dumps(result.get("id_token_claims"), indent=4),
435+
)
436+
self.assertIn(
437+
"access_token", result,
438+
"{error}: {error_description}".format(
439+
# Note: No interpolation here, cause error won't always present
440+
error=result.get("error"),
441+
error_description=result.get("error_description")))
442+
self.assertCacheWorksForUser(result, scope, username=None)
443+
402444
def _test_acquire_token_obo(self, config_pca, config_cca):
403445
# 1. An app obtains a token representing a user, for our mid-tier service
404446
pca = msal.PublicClientApplication(
@@ -500,6 +542,16 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self):
500542
config["port"] = 8080
501543
self._test_acquire_token_by_auth_code(**config)
502544

545+
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
546+
def test_adfs2019_onprem_acquire_token_by_auth_code_flow(self):
547+
config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019")
548+
config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"]
549+
config["scope"] = self.adfs2019_scopes
550+
config["port"] = 8080
551+
self._test_acquire_token_by_auth_code_flow(
552+
username_uri="https://msidlab.com/api/user?usertype=onprem&federationprovider=ADFSv2019",
553+
**config)
554+
503555
@unittest.skipUnless(
504556
os.getenv("LAB_OBO_CLIENT_SECRET"),
505557
"Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56")
@@ -547,6 +599,17 @@ def test_b2c_acquire_token_by_auth_code(self):
547599
scope=config["defaultScopes"].split(','),
548600
)
549601

602+
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
603+
def test_b2c_acquire_token_by_auth_code_flow(self):
604+
config = self.get_lab_app_object(azureenvironment="azureb2ccloud")
605+
self._test_acquire_token_by_auth_code_flow(
606+
authority=self._build_b2c_authority("B2C_1_SignInPolicy"),
607+
client_id=config["appId"],
608+
port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000]
609+
scope=config["defaultScopes"].split(','),
610+
username_uri="https://msidlab.com/api/user?usertype=b2c&b2cprovider=local",
611+
)
612+
550613
def test_b2c_acquire_token_by_ropc(self):
551614
config = self.get_lab_app_object(azureenvironment="azureb2ccloud")
552615
self._test_username_password(

0 commit comments

Comments
 (0)