Skip to content

Commit 06c2b65

Browse files
committed
Implement acquire_token_by_auth_code_flow
1 parent 0b4c157 commit 06c2b65

File tree

2 files changed

+205
-2
lines changed

2 files changed

+205
-2
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,

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)