Skip to content

Commit 4bc07a9

Browse files
authored
Merge pull request #92 from AzureAD/obo
On-behalf-of (OBO) Implementation
2 parents eccf03d + bf98667 commit 4bc07a9

File tree

2 files changed

+81
-3
lines changed

2 files changed

+81
-3
lines changed

msal/application.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ def _acquire_token_by_username_password_federated(
627627
class ConfidentialClientApplication(ClientApplication): # server-side web app
628628

629629
def acquire_token_for_client(self, scopes, **kwargs):
630-
"""Acquires token from the service for the confidential client.
630+
"""Acquires token for the current confidential client, not for an end user.
631631
632632
:param list[str] scopes: (Required)
633633
Scopes requested to access a protected API (a resource).
@@ -642,6 +642,38 @@ def acquire_token_for_client(self, scopes, **kwargs):
642642
scope=scopes, # This grant flow requires no scope decoration
643643
**kwargs)
644644

645-
def acquire_token_on_behalf_of(self, user_assertion, scopes, authority=None):
646-
raise NotImplementedError()
645+
def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs):
646+
"""Acquires token using on-behalf-of (OBO) flow.
647+
648+
The current app is a middle-tier service which was called with a token
649+
representing an end user.
650+
The current app can use such token (a.k.a. a user assertion) to request
651+
another token to access downstream web API, on behalf of that user.
652+
See `detail docs here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow>`_ .
653+
654+
The current middle-tier app has no user interaction to obtain consent.
655+
See how to gain consent upfront for your middle-tier app from this article.
656+
https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#gaining-consent-for-the-middle-tier-application
657+
658+
:param str user_assertion: The incoming token already received by this app
659+
:param list[str] scopes: Scopes required by downstream API (a resource).
660+
661+
:return: A dict representing the json response from AAD:
662+
663+
- A successful response would contain "access_token" key,
664+
- an error response would contain "error" and usually "error_description".
665+
"""
666+
# The implementation is NOT based on Token Exchange
667+
# https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16
668+
return self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
669+
user_assertion,
670+
self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs
671+
scope=decorate_scope(scopes, self.client_id), # Decoration is used for:
672+
# 1. Explicitly requesting an RT, without relying on AAD default
673+
# behavior, even though it currently still issues an RT.
674+
# 2. Requesting an IDT (which would otherwise be unavailable)
675+
# so that the calling app could use id_token_claims to implement
676+
# their own cache mapping, which is likely needed in web apps.
677+
data=dict(kwargs.pop("data", {}), requested_token_use="on_behalf_of"),
678+
**kwargs)
647679

tests/test_e2e.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,49 @@ def test_adfs2019_fed_user(self):
327327
self._test_username_password(
328328
password=self.get_lab_user_secret(config["lab"]["labname"]), **config)
329329

330+
@unittest.skipUnless(
331+
os.getenv("OBO_CLIENT_SECRET"),
332+
"Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret")
333+
def test_acquire_token_obo(self):
334+
# Some hardcoded, pre-defined settings
335+
obo_client_id = "23c64cd8-21e4-41dd-9756-ab9e2c23f58c"
336+
downstream_scopes = ["https://graph.microsoft.com/User.Read"]
337+
config = get_lab_user(isFederated=False)
338+
339+
# 1. An app obtains a token representing a user, for our mid-tier service
340+
pca = msal.PublicClientApplication(
341+
"be9b0186-7dfd-448a-a944-f771029105bf", authority=config.get("authority"))
342+
pca_result = pca.acquire_token_by_username_password(
343+
config["username"],
344+
self.get_lab_user_secret(config["lab"]["labname"]),
345+
scopes=[ # The OBO app's scope. Yours might be different.
346+
"%s/access_as_user" % obo_client_id],
347+
)
348+
self.assertIsNotNone(pca_result.get("access_token"), "PCA should work")
349+
350+
# 2. Our mid-tier service uses OBO to obtain a token for downstream service
351+
cca = msal.ConfidentialClientApplication(
352+
obo_client_id,
353+
client_credential=os.getenv("OBO_CLIENT_SECRET"),
354+
authority=config.get("authority"),
355+
# token_cache= ..., # Default token cache is all-tokens-store-in-memory.
356+
# That's fine if OBO app uses short-lived msal instance per session.
357+
# Otherwise, the OBO app need to implement a one-cache-per-user setup.
358+
)
359+
cca_result = cca.acquire_token_on_behalf_of(
360+
pca_result['access_token'], downstream_scopes)
361+
self.assertNotEqual(None, cca_result.get("access_token"), str(cca_result))
362+
363+
# 3. Now the OBO app can simply store downstream token(s) in same session.
364+
# Alternatively, if you want to persist the downstream AT, and possibly
365+
# the RT (if any) for prolonged access even after your own AT expires,
366+
# now it is the time to persist current cache state for current user.
367+
# Assuming you already did that (which is not shown in this test case),
368+
# the following part shows one of the ways to obtain an AT from cache.
369+
username = cca_result.get("id_token_claims", {}).get("preferred_username")
370+
self.assertEqual(config["username"], username)
371+
if username: # A precaution so that we won't use other user's token
372+
account = cca.get_accounts(username=username)[0]
373+
result = cca.acquire_token_silent(downstream_scopes, account)
374+
self.assertEqual(cca_result["access_token"], result["access_token"])
375+

0 commit comments

Comments
 (0)