Skip to content

Commit 1719e7e

Browse files
authored
Merge pull request #104 from AzureAD/b2c
Per off-line discussion with @jennyf19 and @abhidnya13 , we are now merging this PR because it indeed supports B2C feature with its current API surface. The team will further discuss whether a new B2C high level API would be needed, and then we will follow up.
2 parents 8e83dd6 + d31d472 commit 1719e7e

File tree

3 files changed

+171
-78
lines changed

3 files changed

+171
-78
lines changed

msal/authority.py

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import re
1+
try:
2+
from urllib.parse import urlparse
3+
except ImportError: # Fall back to Python 2
4+
from urlparse import urlparse
25
import logging
36

47
import requests
@@ -15,14 +18,21 @@
1518
'login.microsoftonline.us',
1619
'login.microsoftonline.de',
1720
])
18-
21+
WELL_KNOWN_B2C_HOSTS = [
22+
"b2clogin.com",
23+
"b2clogin.cn",
24+
"b2clogin.us",
25+
"b2clogin.de",
26+
]
1927

2028
class Authority(object):
2129
"""This class represents an (already-validated) authority.
2230
2331
Once constructed, it contains members named "*_endpoint" for this instance.
2432
TODO: It will also cache the previously-validated authority instances.
2533
"""
34+
_domains_without_user_realm_discovery = set([])
35+
2636
def __init__(self, authority_url, validate_authority=True,
2737
verify=True, proxies=None, timeout=None,
2838
):
@@ -37,18 +47,30 @@ def __init__(self, authority_url, validate_authority=True,
3747
self.verify = verify
3848
self.proxies = proxies
3949
self.timeout = timeout
40-
canonicalized, self.instance, tenant = canonicalize(authority_url)
41-
tenant_discovery_endpoint = (
42-
'https://{}/{}{}/.well-known/openid-configuration'.format(
43-
self.instance,
44-
tenant,
45-
"" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint
46-
))
47-
if (tenant != "adfs" and validate_authority
50+
authority, self.instance, tenant = canonicalize(authority_url)
51+
is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS)
52+
if (tenant != "adfs" and (not is_b2c) and validate_authority
4853
and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS):
49-
tenant_discovery_endpoint = instance_discovery(
50-
canonicalized + "/oauth2/v2.0/authorize",
54+
payload = instance_discovery(
55+
"https://{}{}/oauth2/v2.0/authorize".format(
56+
self.instance, authority.path),
5157
verify=verify, proxies=proxies, timeout=timeout)
58+
if payload.get("error") == "invalid_instance":
59+
raise ValueError(
60+
"invalid_instance: "
61+
"The authority you provided, %s, is not whitelisted. "
62+
"If it is indeed your legit customized domain name, "
63+
"you can turn off this check by passing in "
64+
"validate_authority=False"
65+
% authority_url)
66+
tenant_discovery_endpoint = payload['tenant_discovery_endpoint']
67+
else:
68+
tenant_discovery_endpoint = (
69+
'https://{}{}{}/.well-known/openid-configuration'.format(
70+
self.instance,
71+
authority.path, # In B2C scenario, it is "/tenant/policy"
72+
"" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint
73+
))
5274
openid_config = tenant_discovery(
5375
tenant_discovery_endpoint,
5476
verify=verify, proxies=proxies, timeout=timeout)
@@ -58,42 +80,44 @@ def __init__(self, authority_url, validate_authority=True,
5880
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
5981
self.is_adfs = self.tenant.lower() == 'adfs'
6082

61-
def user_realm_discovery(self, username):
62-
resp = requests.get(
63-
"https://{netloc}/common/userrealm/{username}?api-version=1.0".format(
64-
netloc=self.instance, username=username),
65-
headers={'Accept':'application/json'},
66-
verify=self.verify, proxies=self.proxies, timeout=self.timeout)
67-
resp.raise_for_status()
68-
return resp.json()
69-
# It will typically contain "ver", "account_type",
83+
def user_realm_discovery(self, username, response=None):
84+
# It will typically return a dict containing "ver", "account_type",
7085
# "federation_protocol", "cloud_audience_urn",
7186
# "federation_metadata_url", "federation_active_auth_url", etc.
87+
if self.instance not in self.__class__._domains_without_user_realm_discovery:
88+
resp = response or requests.get(
89+
"https://{netloc}/common/userrealm/{username}?api-version=1.0".format(
90+
netloc=self.instance, username=username),
91+
headers={'Accept':'application/json'},
92+
verify=self.verify, proxies=self.proxies, timeout=self.timeout)
93+
if resp.status_code != 404:
94+
resp.raise_for_status()
95+
return resp.json()
96+
self.__class__._domains_without_user_realm_discovery.add(self.instance)
97+
return {} # This can guide the caller to fall back normal ROPC flow
98+
7299

73-
def canonicalize(url):
74-
# Returns (canonicalized_url, netloc, tenant). Raises ValueError on errors.
75-
match_object = re.match(r'https://([^/]+)/([^/?#]+)', url.lower())
76-
if not match_object:
100+
def canonicalize(authority_url):
101+
authority = urlparse(authority_url)
102+
parts = authority.path.split("/")
103+
if authority.scheme != "https" or len(parts) < 2 or not parts[1]:
77104
raise ValueError(
78105
"Your given address (%s) should consist of "
79106
"an https url with a minimum of one segment in a path: e.g. "
80-
"https://login.microsoftonline.com/<tenant_name>" % url)
81-
return match_object.group(0), match_object.group(1), match_object.group(2)
107+
"https://login.microsoftonline.com/<tenant> "
108+
"or https://<tenant_name>.b2clogin.com/<tenant_name>.onmicrosoft.com/policy"
109+
% authority_url)
110+
return authority, authority.netloc, parts[1]
82111

83-
def instance_discovery(url, response=None, **kwargs):
84-
# Returns tenant discovery endpoint
85-
resp = requests.get( # Note: This URL seemingly returns V1 endpoint only
112+
def instance_discovery(url, **kwargs):
113+
return requests.get( # Note: This URL seemingly returns V1 endpoint only
86114
'https://{}/common/discovery/instance'.format(
87115
WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too
88116
# See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103
89117
# and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33
90118
),
91119
params={'authorization_endpoint': url, 'api-version': '1.0'},
92-
**kwargs)
93-
payload = response or resp.json()
94-
if 'tenant_discovery_endpoint' not in payload:
95-
raise MsalServiceError(status_code=resp.status_code, **payload)
96-
return payload['tenant_discovery_endpoint']
120+
**kwargs).json()
97121

98122
def tenant_discovery(tenant_discovery_endpoint, **kwargs):
99123
# Returns Openid Configuration

tests/test_authority.py

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import os
2+
13
from msal.authority import *
24
from msal.exceptions import MsalServiceError
35
from tests import unittest
46

57

8+
@unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip network io during tagged release")
69
class TestAuthority(unittest.TestCase):
710

811
def test_wellknown_host_and_tenant(self):
@@ -26,7 +29,7 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self):
2629
self.assertNotIn('v2.0', a.token_endpoint)
2730

2831
def test_unknown_host_wont_pass_instance_discovery(self):
29-
with self.assertRaisesRegexp(MsalServiceError, "invalid_instance"):
32+
with self.assertRaisesRegexp(ValueError, "invalid_instance"):
3033
Authority('https://unknown.host/tenant_doesnt_matter_in_this_case')
3134

3235
def test_invalid_host_skipping_validation_meets_connection_error_down_the_road(self):
@@ -37,19 +40,19 @@ def test_invalid_host_skipping_validation_meets_connection_error_down_the_road(s
3740
class TestAuthorityInternalHelperCanonicalize(unittest.TestCase):
3841

3942
def test_canonicalize_tenant_followed_by_extra_paths(self):
40-
self.assertEqual(
41-
canonicalize("https://example.com/tenant/subpath?foo=bar#fragment"),
42-
("https://example.com/tenant", "example.com", "tenant"))
43+
_, i, t = canonicalize("https://example.com/tenant/subpath?foo=bar#fragment")
44+
self.assertEqual("example.com", i)
45+
self.assertEqual("tenant", t)
4346

4447
def test_canonicalize_tenant_followed_by_extra_query(self):
45-
self.assertEqual(
46-
canonicalize("https://example.com/tenant?foo=bar#fragment"),
47-
("https://example.com/tenant", "example.com", "tenant"))
48+
_, i, t = canonicalize("https://example.com/tenant?foo=bar#fragment")
49+
self.assertEqual("example.com", i)
50+
self.assertEqual("tenant", t)
4851

4952
def test_canonicalize_tenant_followed_by_extra_fragment(self):
50-
self.assertEqual(
51-
canonicalize("https://example.com/tenant#fragment"),
52-
("https://example.com/tenant", "example.com", "tenant"))
53+
_, i, t = canonicalize("https://example.com/tenant#fragment")
54+
self.assertEqual("example.com", i)
55+
self.assertEqual("tenant", t)
5356

5457
def test_canonicalize_rejects_non_https(self):
5558
with self.assertRaises(ValueError):
@@ -64,20 +67,22 @@ def test_canonicalize_rejects_tenantless_host_with_trailing_slash(self):
6467
canonicalize("https://no.tenant.example.com/")
6568

6669

67-
class TestAuthorityInternalHelperInstanceDiscovery(unittest.TestCase):
68-
69-
def test_instance_discovery_happy_case(self):
70-
self.assertEqual(
71-
instance_discovery("https://login.windows.net/tenant"),
72-
"https://login.windows.net/tenant/.well-known/openid-configuration")
73-
74-
def test_instance_discovery_with_unknown_instance(self):
75-
with self.assertRaisesRegexp(MsalServiceError, "invalid_instance"):
76-
instance_discovery('https://unknown.host/tenant_doesnt_matter_here')
77-
78-
def test_instance_discovery_with_mocked_response(self):
79-
mock_response = {'tenant_discovery_endpoint': 'http://a.com/t/openid'}
80-
endpoint = instance_discovery(
81-
"https://login.microsoftonline.in/tenant.com", response=mock_response)
82-
self.assertEqual(endpoint, mock_response['tenant_discovery_endpoint'])
70+
@unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip network io during tagged release")
71+
class TestAuthorityInternalHelperUserRealmDiscovery(unittest.TestCase):
72+
def test_memorize(self):
73+
# We use a real authority so the constructor can finish tenant discovery
74+
authority = "https://login.microsoftonline.com/common"
75+
self.assertNotIn(authority, Authority._domains_without_user_realm_discovery)
76+
a = Authority(authority, validate_authority=False)
77+
78+
# We now pretend this authority supports no User Realm Discovery
79+
class MockResponse(object):
80+
status_code = 404
81+
a.user_realm_discovery("[email protected]", response=MockResponse())
82+
self.assertIn(
83+
"login.microsoftonline.com",
84+
Authority._domains_without_user_realm_discovery,
85+
"user_realm_discovery() should memorize domains not supporting URD")
86+
a.user_realm_discovery("[email protected]",
87+
response="This would cause exception if memorization did not work")
8388

tests/test_e2e.py

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@
1313
logging.basicConfig(level=logging.INFO)
1414

1515

16+
def _get_app_and_auth_code(
17+
client_id,
18+
client_secret=None,
19+
authority="https://login.microsoftonline.com/common",
20+
port=44331,
21+
scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph
22+
):
23+
from msal.oauth2cli.authcode import obtain_auth_code
24+
app = msal.ClientApplication(client_id, client_secret, authority=authority)
25+
redirect_uri = "http://localhost:%d" % port
26+
ac = obtain_auth_code(port, auth_uri=app.get_authorization_request_url(
27+
scopes, redirect_uri=redirect_uri))
28+
assert ac is not None
29+
return (app, ac, redirect_uri)
30+
1631
@unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip e2e tests during tagged release")
1732
class E2eTestCase(unittest.TestCase):
1833

@@ -49,9 +64,15 @@ def assertCacheWorksForUser(self, result_from_wire, scope, username=None):
4964
result_from_cache = self.app.acquire_token_silent(scope, account=account)
5065
self.assertIsNotNone(result_from_cache,
5166
"We should get a result from acquire_token_silent(...) call")
52-
self.assertNotEqual(
53-
result_from_wire['access_token'], result_from_cache['access_token'],
54-
"We should get a fresh AT (via RT)")
67+
self.assertIsNotNone(
68+
# We used to assert it this way:
69+
# result_from_wire['access_token'] != result_from_cache['access_token']
70+
# but ROPC in B2C tends to return the same AT we obtained seconds ago.
71+
# Now looking back, "refresh_token grant would return a brand new AT"
72+
# was just an empirical observation but never a committment in specs,
73+
# so we adjust our way to assert here.
74+
(result_from_cache or {}).get("access_token"),
75+
"We should get an AT from acquire_token_silent(...) call")
5576

5677
def assertCacheWorksForApp(self, result_from_wire, scope):
5778
# Going to test acquire_token_silent(...) to locate an AT from cache
@@ -70,7 +91,10 @@ def _test_username_password(self,
7091
username, password, scopes=scope)
7192
self.assertLoosely(result)
7293
# self.assertEqual(None, result.get("error"), str(result))
73-
self.assertCacheWorksForUser(result, scope, username=username)
94+
self.assertCacheWorksForUser(
95+
result, scope,
96+
username=username if ".b2clogin.com" not in authority else None,
97+
)
7498

7599

76100
THIS_FOLDER = os.path.dirname(__file__)
@@ -95,23 +119,17 @@ def test_username_password(self):
95119
self._test_username_password(**self.config)
96120

97121
def _get_app_and_auth_code(self):
98-
from msal.oauth2cli.authcode import obtain_auth_code
99-
app = msal.ClientApplication(
122+
return _get_app_and_auth_code(
100123
self.config["client_id"],
101-
client_credential=self.config.get("client_secret"),
102-
authority=self.config.get("authority"))
103-
port = self.config.get("listen_port", 44331)
104-
redirect_uri = "http://localhost:%s" % port
105-
auth_request_uri = app.get_authorization_request_url(
106-
self.config["scope"], redirect_uri=redirect_uri)
107-
ac = obtain_auth_code(port, auth_uri=auth_request_uri)
108-
self.assertNotEqual(ac, None)
109-
return (app, ac, redirect_uri)
124+
client_secret=self.config.get("client_secret"),
125+
authority=self.config.get("authority"),
126+
port=self.config.get("listen_port", 44331),
127+
scopes=self.config["scope"],
128+
)
110129

111130
def test_auth_code(self):
112131
self.skipUnlessWithConfig(["client_id", "scope"])
113132
(self.app, ac, redirect_uri) = self._get_app_and_auth_code()
114-
115133
result = self.app.acquire_token_by_authorization_code(
116134
ac, self.config["scope"], redirect_uri=redirect_uri)
117135
logger.debug("%s.cache = %s",
@@ -314,7 +332,7 @@ def get_lab_user_secret(cls, lab_name="msidlab4"):
314332
lab_name = lab_name.lower()
315333
if lab_name not in cls._secrets:
316334
logger.info("Querying lab user password for %s", lab_name)
317-
# Note: Short link won't work "https://aka.ms/GetLabUserSecret?Secret=%s"
335+
# Short link only works in browser "https://aka.ms/GetLabUserSecret?Secret=%s"
318336
# So we use the official link written in here
319337
# https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API%27s.aspx
320338
url = ("https://request.msidlab.com/api/GetLabUserSecret?code=KpY5uCcoKo0aW8VOL/CUO3wnu9UF2XbSnLFGk56BDnmQiwD80MQ7HA==&Secret=%s"
@@ -417,3 +435,49 @@ def test_acquire_token_obo(self):
417435
result = cca.acquire_token_silent(downstream_scopes, account)
418436
self.assertEqual(cca_result["access_token"], result["access_token"])
419437

438+
def _build_b2c_authority(self, policy):
439+
base = "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com"
440+
return base + "/" + policy # We do not support base + "?p=" + policy
441+
442+
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
443+
def test_b2c_acquire_token_by_auth_code(self):
444+
"""
445+
When prompted, you can manually login using this account:
446+
447+
username="[email protected]"
448+
# This won't work https://msidlab.com/api/user?usertype=b2c
449+
password="***" # From https://aka.ms/GetLabUserSecret?Secret=msidlabb2c
450+
"""
451+
scopes = ["https://msidlabb2c.onmicrosoft.com/msaapp/user_impersonation"]
452+
(self.app, ac, redirect_uri) = _get_app_and_auth_code(
453+
"b876a048-55a5-4fc5-9403-f5d90cb1c852",
454+
client_secret=self.get_lab_user_secret("MSIDLABB2C-MSAapp-AppSecret"),
455+
authority=self._build_b2c_authority("B2C_1_SignInPolicy"),
456+
port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000]
457+
scopes=scopes,
458+
)
459+
result = self.app.acquire_token_by_authorization_code(
460+
ac, scopes, redirect_uri=redirect_uri)
461+
logger.debug(
462+
"%s: cache = %s, id_token_claims = %s",
463+
self.id(),
464+
json.dumps(self.app.token_cache._cache, indent=4),
465+
json.dumps(result.get("id_token_claims"), indent=4),
466+
)
467+
self.assertIn(
468+
"access_token", result,
469+
"{error}: {error_description}".format(
470+
# Note: No interpolation here, cause error won't always present
471+
error=result.get("error"),
472+
error_description=result.get("error_description")))
473+
self.assertCacheWorksForUser(result, scopes, username=None)
474+
475+
def test_b2c_acquire_token_by_ropc(self):
476+
self._test_username_password(
477+
authority=self._build_b2c_authority("B2C_1_ROPC_Auth"),
478+
client_id="e3b9ad76-9763-4827-b088-80c7a7888f79",
479+
username="[email protected]",
480+
password=self.get_lab_user_secret("msidlabb2c"),
481+
scope=["https://msidlabb2c.onmicrosoft.com/msidlabb2capi/read"],
482+
)
483+

0 commit comments

Comments
 (0)