diff --git a/README.md b/README.md index f570672c..eb134674 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ Quick links: | [Getting Started](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-python-webapp) | [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) | | --- | --- | --- | --- | --- | +## Scenarios supported + +Click on the following thumbnail to visit a large map with clickable links to proper samples. + +[![Map effect won't work inside github's markdown file, so we have to use a thumbnail here to lure audience to a real static website](docs/thumbnail.png)](https://msal-python.readthedocs.io/en/latest/) + ## Installation You can find MSAL Python on [Pypi](https://pypi.org/project/msal/). diff --git a/docs/daemon-app.svg b/docs/daemon-app.svg new file mode 100644 index 00000000..8f1af659 --- /dev/null +++ b/docs/daemon-app.svg @@ -0,0 +1,1074 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + Page-1 + + + + + Web app (Was Websites).1277 + Daemon Web app + + Sheet.1002 + + Sheet.1003 + + + + + Sheet.1004 + + Sheet.1005 + + Sheet.1006 + + Sheet.1007 + + + + + Sheet.1008 + + + + Sheet.1009 + + Sheet.1010 + + + + + Sheet.1011 + + Sheet.1012 + + + + + Sheet.1013 + + Sheet.1014 + + + + + Sheet.1015 + + Sheet.1016 + + + + + Sheet.1017 + + Sheet.1018 + + + + + Sheet.1019 + + Sheet.1020 + + + + + Sheet.1021 + + + + Sheet.1022 + + + + + Sheet.1023 + + Sheet.1024 + + + + + Sheet.1025 + + Sheet.1026 + + + + + Sheet.1027 + + Sheet.1028 + + + + + + + + DaemonWeb app + + + + API App.1305 + Daemon API App + + + + DaemonAPI App + + + Microsoft Enterprise desktop virtualization.1317 + Daemon Desktop App + + Sheet.1031 + + + + Sheet.1032 + + + + Sheet.1033 + + + + Sheet.1034 + + + + Sheet.1035 + + + + Sheet.1036 + + + + Sheet.1037 + + + + Sheet.1038 + + + + + + DaemonDesktop App + + + + Certificate.1337 + Secret + + Sheet.1040 + + + + Sheet.1041 + + Sheet.1042 + + Sheet.1043 + + + + + Sheet.1044 + + Sheet.1045 + + + + + Sheet.1046 + + Sheet.1047 + + + + + + + + Secret + + + + Arrow (Azure Poster Style).1346 + + + + Arrow (Azure Poster Style).1348 + + + + API App.1350 + Daemon Web API + + + + Daemon Web API + + + Certificate.1385 + Secret + + Sheet.1052 + + + + Sheet.1053 + + Sheet.1054 + + Sheet.1055 + + + + + Sheet.1056 + + Sheet.1057 + + + + + Sheet.1058 + + Sheet.1059 + + + + + + + + Secret + + + + Certificate.1416 + Secret + + Sheet.1061 + + + + Sheet.1062 + + Sheet.1063 + + Sheet.1064 + + + + + Sheet.1065 + + Sheet.1066 + + + + + Sheet.1067 + + Sheet.1068 + + + + + + + + Secret + + + + Arrow (Azure Poster Style).1507 + + + + Sheet.1215 + Client Credentials flow + + + + Client Credentials flow + + + \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 439ca0ee..95b89b98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,17 +10,67 @@ MSAL Python documentation GitHub Repository You can find high level conceptual documentations in the project -`README `_ -and -`workable samples inside the project code base -`_ -. +`README `_. + +Scenarios +========= + +There are many `different application scenarios `_. +MSAL Python supports some of them. +**The following diagram serves as a map. Locate your application scenario on the map.** +**If the corresponding icon is clickable, it will bring you to an MSAL Python sample for that scenario.** + +* Most authentication scenarios acquire tokens on behalf of signed-in users. + + .. raw:: html + + + + + + Web app + Web app + Desktop App + + Browserless app + + +* There are also daemon apps. In these scenarios, applications acquire tokens on behalf of themselves with no user. + + .. raw:: html + + + + + + Daemon App acquires token for themselves + + +* There are other less common samples, such for ADAL-to-MSAL migration, + `available inside the project code base + `_. -The documentation hosted here is for API Reference. API === +The following section is the API Reference of MSAL Python. + +.. note:: + + Only APIs and their parameters documented in this section are part of public API, + with guaranteed backward compatibility for the entire 1.x series. + + Other modules in the source code are all considered as internal helpers, + which could change at anytime in the future, without prior notice. + MSAL proposes a clean separation between `public client applications and confidential client applications `_. @@ -35,6 +85,8 @@ PublicClientApplication :members: :inherited-members: + .. automethod:: __init__ + ConfidentialClientApplication ----------------------------- @@ -42,6 +94,8 @@ ConfidentialClientApplication :members: :inherited-members: + .. automethod:: __init__ + TokenCache ---------- diff --git a/docs/scenarios-with-users.svg b/docs/scenarios-with-users.svg new file mode 100644 index 00000000..fffdec47 --- /dev/null +++ b/docs/scenarios-with-users.svg @@ -0,0 +1,2789 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + Page-1 + + + + + Web app (Was Websites).1073 + Single Page Application + + Sheet.1074 + + Sheet.1075 + + + + + Sheet.1076 + + Sheet.1077 + + Sheet.1078 + + Sheet.1079 + + + + + Sheet.1080 + + + + Sheet.1081 + + Sheet.1082 + + + + + Sheet.1083 + + Sheet.1084 + + + + + Sheet.1085 + + Sheet.1086 + + + + + Sheet.1087 + + Sheet.1088 + + + + + Sheet.1089 + + Sheet.1090 + + + + + Sheet.1091 + + Sheet.1092 + + + + + Sheet.1093 + + + + Sheet.1094 + + + + + Sheet.1095 + + Sheet.1096 + + + + + Sheet.1097 + + Sheet.1098 + + + + + Sheet.1099 + + Sheet.1100 + + + + + + + + Single Page Application + + + + Web app (Was Websites).1101 + Web app + + Sheet.1102 + + Sheet.1103 + + + + + Sheet.1104 + + Sheet.1105 + + Sheet.1106 + + Sheet.1107 + + + + + Sheet.1108 + + + + Sheet.1109 + + Sheet.1110 + + + + + Sheet.1111 + + Sheet.1112 + + + + + Sheet.1113 + + Sheet.1114 + + + + + Sheet.1115 + + Sheet.1116 + + + + + Sheet.1117 + + Sheet.1118 + + + + + Sheet.1119 + + Sheet.1120 + + + + + Sheet.1121 + + + + Sheet.1122 + + + + + Sheet.1123 + + Sheet.1124 + + + + + Sheet.1125 + + Sheet.1126 + + + + + Sheet.1127 + + Sheet.1128 + + + + + + + + Web app + + + + API App.1129 + API App + + + + API App + + + IoT Hub.1130 + Browserless app + + Sheet.1131 + + Sheet.1132 + + Sheet.1133 + + Sheet.1134 + + + Sheet.1135 + + + + + Sheet.1136 + + + + + + + Browserlessapp + + + + Mobile App (Was Mobile Services).1137 + Mobile App + + Sheet.1138 + + + + Sheet.1139 + + + + + + MobileApp + + + + Arrow (Azure Poster Style).1140 + + + + Microsoft Enterprise desktop virtualization.1141 + Desktop App + + Sheet.1142 + + + + Sheet.1143 + + + + Sheet.1144 + + + + Sheet.1145 + + + + Sheet.1146 + + + + Sheet.1147 + + + + Sheet.1148 + + + + Sheet.1149 + + + + + + Desktop App + + + + User Permissions.1150 + + Sheet.1151 + + Sheet.1152 + + Sheet.1153 + + Sheet.1154 + + + + + Sheet.1155 + + Sheet.1156 + + + + + + + Sheet.1157 + + Sheet.1158 + + + + Sheet.1159 + + + + Sheet.1160 + + + + + + Arrow (Azure Poster Style).1170 + + + + Arrow (Azure Poster Style).1171 + + + + Arrow (Azure Poster Style).1172 + + + + Arrow (Azure Poster Style).1173 + + + + API App.1174 + API App + + + + API App + + + Arrow (Azure Poster Style).1175 + + + + User Permissions.1176 + + Sheet.1177 + + Sheet.1178 + + Sheet.1179 + + Sheet.1180 + + + + + Sheet.1181 + + Sheet.1182 + + + + + + + Sheet.1183 + + Sheet.1184 + + + + Sheet.1185 + + + + Sheet.1186 + + + + + + User Permissions.1187 + + Sheet.1188 + + Sheet.1189 + + Sheet.1190 + + Sheet.1191 + + + + + Sheet.1192 + + Sheet.1193 + + + + + + + Sheet.1194 + + Sheet.1195 + + + + Sheet.1196 + + + + Sheet.1197 + + + + + + User Permissions.1198 + + Sheet.1199 + + Sheet.1200 + + Sheet.1201 + + Sheet.1202 + + + + + Sheet.1203 + + Sheet.1204 + + + + + + + Sheet.1205 + + Sheet.1206 + + + + Sheet.1207 + + + + Sheet.1208 + + + + + + User Permissions.1218 + + Sheet.1219 + + Sheet.1220 + + Sheet.1221 + + Sheet.1222 + + + + + Sheet.1223 + + Sheet.1224 + + + + + + + Sheet.1225 + + Sheet.1226 + + + + Sheet.1227 + + + + Sheet.1228 + + + + + + Web app (Was Websites).1425 + Single Page Application + + Sheet.1426 + + Sheet.1427 + + + + + Sheet.1428 + + Sheet.1429 + + Sheet.1430 + + Sheet.1431 + + + + + Sheet.1432 + + + + Sheet.1433 + + Sheet.1434 + + + + + Sheet.1435 + + Sheet.1436 + + + + + Sheet.1437 + + Sheet.1438 + + + + + Sheet.1439 + + Sheet.1440 + + + + + Sheet.1441 + + Sheet.1442 + + + + + Sheet.1443 + + Sheet.1444 + + + + + Sheet.1445 + + + + Sheet.1446 + + + + + Sheet.1447 + + Sheet.1448 + + + + + Sheet.1449 + + Sheet.1450 + + + + + Sheet.1451 + + Sheet.1452 + + + + + + + + Single Page Application + + + + User Permissions.1453 + + Sheet.1454 + + Sheet.1455 + + Sheet.1456 + + Sheet.1457 + + + + + Sheet.1458 + + Sheet.1459 + + + + + + + Sheet.1460 + + Sheet.1461 + + + + Sheet.1462 + + + + Sheet.1463 + + + + + + Web app (Was Websites).1464 + Web app + + Sheet.1465 + + Sheet.1466 + + + + + Sheet.1467 + + Sheet.1468 + + Sheet.1469 + + Sheet.1470 + + + + + Sheet.1471 + + + + Sheet.1472 + + Sheet.1473 + + + + + Sheet.1474 + + Sheet.1475 + + + + + Sheet.1476 + + Sheet.1477 + + + + + Sheet.1478 + + Sheet.1479 + + + + + Sheet.1480 + + Sheet.1481 + + + + + Sheet.1482 + + Sheet.1483 + + + + + Sheet.1484 + + + + Sheet.1485 + + + + + Sheet.1486 + + Sheet.1487 + + + + + Sheet.1488 + + Sheet.1489 + + + + + Sheet.1490 + + Sheet.1491 + + + + + + + + Web app + + + + User Permissions.1492 + + Sheet.1493 + + Sheet.1494 + + Sheet.1495 + + Sheet.1496 + + + + + Sheet.1497 + + Sheet.1498 + + + + + + + Sheet.1499 + + Sheet.1500 + + + + Sheet.1501 + + + + Sheet.1502 + + + + + + Pentagon.1683 + + + + + + + User Permissions.1684 + + Sheet.1685 + + Sheet.1686 + + Sheet.1687 + + Sheet.1688 + + + + + Sheet.1689 + + Sheet.1690 + + + + + + + Sheet.1691 + + Sheet.1692 + + + + Sheet.1693 + + + + Sheet.1694 + + + + + + \ No newline at end of file diff --git a/docs/thumbnail.png b/docs/thumbnail.png new file mode 100644 index 00000000..e1606e91 Binary files /dev/null and b/docs/thumbnail.png differ diff --git a/msal/__init__.py b/msal/__init__.py index 824363fb..4e2faaed 100644 --- a/msal/__init__.py +++ b/msal/__init__.py @@ -31,5 +31,6 @@ ConfidentialClientApplication, PublicClientApplication, ) +from .oauth2cli.oidc import Prompt from .token_cache import TokenCache, SerializableTokenCache diff --git a/msal/application.py b/msal/application.py index 6ab23dfe..cb2ee87a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -19,40 +19,14 @@ from .wstrust_response import * from .token_cache import TokenCache import msal.telemetry +from .region import _detect_region # The __init__.py will import this. Not the other way around. -__version__ = "1.11.0" +__version__ = "1.12.0" logger = logging.getLogger(__name__) -def decorate_scope( - scopes, client_id, - reserved_scope=frozenset(['openid', 'profile', 'offline_access'])): - if not isinstance(scopes, (list, set, tuple)): - raise ValueError("The input scopes should be a list, tuple, or set") - scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set. - if scope_set & reserved_scope: - # These scopes are reserved for the API to provide good experience. - # We could make the developer pass these and then if they do they will - # come back asking why they don't see refresh token or user information. - raise ValueError( - "API does not accept {} value as user-provided scopes".format( - reserved_scope)) - if client_id in scope_set: - if len(scope_set) > 1: - # We make developers pass their client id, so that they can express - # the intent that they want the token for themselves (their own - # app). - # If we do not restrict them to passing only client id then they - # could write code where they expect an id token but end up getting - # access_token. - raise ValueError("Client Id can only be provided as a single scope") - decorated = set(reserved_scope) # Make a writable copy - else: - decorated = scope_set | reserved_scope - return list(decorated) - def extract_certs(public_cert_content): # Parses raw public certificate file contents and returns a list of strings @@ -108,6 +82,8 @@ class ClientApplication(object): GET_ACCOUNTS_ID = "902" REMOVE_ACCOUNT_ID = "903" + ATTEMPT_REGION_DISCOVERY = True # "TryAutoDetect" + def __init__( self, client_id, client_credential=None, authority=None, validate_authority=True, @@ -115,7 +91,13 @@ def __init__( http_client=None, verify=True, proxies=None, timeout=None, client_claims=None, app_name=None, app_version=None, - client_capabilities=None): + client_capabilities=None, + azure_region=None, # Note: We choose to add this param in this base class, + # despite it is currently only needed by ConfidentialClientApplication. + # This way, it holds the same positional param place for PCA, + # when we would eventually want to add this feature to PCA in future. + exclude_scopes=None, + ): """Create an instance of application. :param str client_id: Your app has a client_id after you register it on AAD. @@ -220,11 +202,75 @@ def __init__( MSAL will combine them into `claims parameter `_. + + 4. An app which already onboard to the region's allow-list. + + MSAL's default value is None, which means region behavior remains off. + If enabled, the `acquire_token_for_client()`-relevant traffic + would remain inside that region. + + App developer can opt in to a regional endpoint, + by provide its region name, such as "westus", "eastus2". + You can find a full list of regions by running + ``az account list-locations -o table``, or referencing to + `this doc `_. + + An app running inside Azure Functions and Azure VM can use a special keyword + ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region. + + .. note:: + + Setting ``azure_region`` to non-``None`` for an app running + outside of Azure Function/VM could hang indefinitely. + + You should consider opting in/out region behavior on-demand, + by loading ``azure_region=None`` or ``azure_region="westus"`` + or ``azure_region=True`` (which means opt-in and auto-detect) + from your per-deployment configuration, and then do + ``app = ConfidentialClientApplication(..., azure_region=azure_region)``. + + Alternatively, you can configure a short timeout, + or provide a custom http_client which has a short timeout. + That way, the latency would be under your control, + but still less performant than opting out of region feature. + :param list[str] exclude_scopes: (optional) + Historically MSAL hardcodes `offline_access` scope, + which would allow your app to have prolonged access to user's data. + If that is unnecessary or undesirable for your app, + now you can use this parameter to supply an exclusion list of scopes, + such as ``exclude_scopes = ["offline_access"]``. """ self.client_id = client_id self.client_credential = client_credential self.client_claims = client_claims self._client_capabilities = client_capabilities + + if exclude_scopes and not isinstance(exclude_scopes, list): + raise ValueError( + "Invalid exclude_scopes={}. It need to be a list of strings.".format( + repr(exclude_scopes))) + self._exclude_scopes = frozenset(exclude_scopes or []) + if "openid" in self._exclude_scopes: + raise ValueError( + 'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format( + repr(exclude_scopes))) + if http_client: self.http_client = http_client else: @@ -244,22 +290,93 @@ def __init__( self.app_name = app_name self.app_version = app_version - self.authority = Authority( + + # Here the self.authority will not be the same type as authority in input + try: + self.authority = Authority( authority or "https://login.microsoftonline.com/common/", self.http_client, validate_authority=validate_authority) - # Here the self.authority is not the same type as authority in input + except ValueError: # Those are explicit authority validation errors + raise + except Exception: # The rest are typically connection errors + if validate_authority and azure_region: + # Since caller opts in to use region, here we tolerate connection + # errors happened during authority validation at non-region endpoint + self.authority = Authority( + authority or "https://login.microsoftonline.com/common/", + self.http_client, validate_authority=False) + else: + raise + self.token_cache = token_cache or TokenCache() - self.client = self._build_client(client_credential, self.authority) + self._region_configured = azure_region + self._region_detected = None + self.client, self._regional_client = self._build_client( + client_credential, self.authority) self.authority_groups = None self._telemetry_buffer = {} self._telemetry_lock = Lock() + def _decorate_scope( + self, scopes, + reserved_scope=frozenset(['openid', 'profile', 'offline_access'])): + if not isinstance(scopes, (list, set, tuple)): + raise ValueError("The input scopes should be a list, tuple, or set") + scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set. + if scope_set & reserved_scope: + # These scopes are reserved for the API to provide good experience. + # We could make the developer pass these and then if they do they will + # come back asking why they don't see refresh token or user information. + raise ValueError( + "API does not accept {} value as user-provided scopes".format( + reserved_scope)) + if self.client_id in scope_set: + if len(scope_set) > 1: + # We make developers pass their client id, so that they can express + # the intent that they want the token for themselves (their own + # app). + # If we do not restrict them to passing only client id then they + # could write code where they expect an id token but end up getting + # access_token. + raise ValueError("Client Id can only be provided as a single scope") + decorated = set(reserved_scope) # Make a writable copy + else: + decorated = scope_set | reserved_scope + decorated -= self._exclude_scopes + return list(decorated) + def _build_telemetry_context( self, api_id, correlation_id=None, refresh_reason=None): return msal.telemetry._TelemetryContext( self._telemetry_buffer, self._telemetry_lock, api_id, correlation_id=correlation_id, refresh_reason=refresh_reason) + def _get_regional_authority(self, central_authority): + is_region_specified = bool(self._region_configured + and self._region_configured != self.ATTEMPT_REGION_DISCOVERY) + self._region_detected = self._region_detected or _detect_region( + self.http_client if self._region_configured is not None else None) + if (is_region_specified and self._region_configured != self._region_detected): + logger.warning('Region configured ({}) != region detected ({})'.format( + repr(self._region_configured), repr(self._region_detected))) + region_to_use = ( + self._region_configured if is_region_specified else self._region_detected) + if region_to_use: + logger.info('Region to be used: {}'.format(repr(region_to_use))) + regional_host = ("{}.login.microsoft.com".format(region_to_use) + if central_authority.instance in ( + # The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328 + "login.microsoftonline.com", + "login.windows.net", + "sts.windows.net", + ) + else "{}.{}".format(region_to_use, central_authority.instance)) + return Authority( + "https://{}/{}".format(regional_host, central_authority.tenant), + self.http_client, + validate_authority=False) # The central_authority has already been validated + return None + def _build_client(self, client_credential, authority): client_assertion = None client_assertion_type = None @@ -298,15 +415,15 @@ def _build_client(self, client_credential, authority): client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: default_body['client_secret'] = client_credential - server_configuration = { + central_configuration = { "authorization_endpoint": authority.authorization_endpoint, "token_endpoint": authority.token_endpoint, "device_authorization_endpoint": authority.device_authorization_endpoint or urljoin(authority.token_endpoint, "devicecode"), } - return Client( - server_configuration, + central_client = Client( + central_configuration, self.client_id, http_client=self.http_client, default_headers=default_headers, @@ -318,6 +435,31 @@ def _build_client(self, client_credential, authority): on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt) + regional_client = None + if client_credential: # Currently regional endpoint only serves some CCA flows + regional_authority = self._get_regional_authority(authority) + if regional_authority: + regional_configuration = { + "authorization_endpoint": regional_authority.authorization_endpoint, + "token_endpoint": regional_authority.token_endpoint, + "device_authorization_endpoint": + regional_authority.device_authorization_endpoint or + urljoin(regional_authority.token_endpoint, "devicecode"), + } + regional_client = Client( + regional_configuration, + self.client_id, + http_client=self.http_client, + default_headers=default_headers, + default_body=default_body, + client_assertion=client_assertion, + client_assertion_type=client_assertion_type, + on_obtaining_tokens=lambda event: self.token_cache.add(dict( + event, environment=authority.instance)), + on_removing_rt=self.token_cache.remove_rt, + on_updating_rt=self.token_cache.update_rt) + return central_client, regional_client + def initiate_auth_code_flow( self, scopes, # type: list[str] @@ -382,7 +524,7 @@ def initiate_auth_code_flow( flow = client.initiate_auth_code_flow( redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), domain_hint=domain_hint, claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), @@ -464,7 +606,7 @@ def get_authorization_request_url( response_type=response_type, redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), nonce=nonce, domain_hint=domain_hint, claims=_merge_claims_challenge_and_capabilities( @@ -527,7 +669,7 @@ def authorize(): # A controller in a web app response =_clean_up(self.client.obtain_token_by_auth_code_flow( auth_code_flow, auth_response, - scope=decorate_scope(scopes, self.client_id) if scopes else None, + scope=self._decorate_scope(scopes) if scopes else None, headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), @@ -599,7 +741,7 @@ def acquire_token_by_authorization_code( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) response = _clean_up(self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), @@ -634,6 +776,13 @@ def get_accounts(self, username=None): lowercase_username = username.lower() accounts = [a for a in accounts if a["username"].lower() == lowercase_username] + if not accounts: + logger.warning(( + "get_accounts(username='{}') finds no account. " + "If tokens were acquired without 'profile' scope, " + "they would contain no username for filtering. " + "Consider calling get_accounts(username=None) instead." + ).format(username)) # Does not further filter by existing RTs here. It probably won't matter. # Because in most cases Accounts and RTs co-exist. # Even in the rare case when an RT is revoked and then removed, @@ -642,10 +791,25 @@ def get_accounts(self, username=None): return accounts def _find_msal_accounts(self, environment): - return [a for a in self.token_cache.find( - TokenCache.CredentialType.ACCOUNT, query={"environment": environment}) + grouped_accounts = { + a.get("home_account_id"): # Grouped by home tenant's id + { # These are minimal amount of non-tenant-specific account info + "home_account_id": a.get("home_account_id"), + "environment": a.get("environment"), + "username": a.get("username"), + + # The following fields for backward compatibility, for now + "authority_type": a.get("authority_type"), + "local_account_id": a.get("local_account_id"), # Tenant-specific + "realm": a.get("realm"), # Tenant-specific + } + for a in self.token_cache.find( + TokenCache.CredentialType.ACCOUNT, + query={"environment": environment}) if a["authority_type"] in ( - TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)] + TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS) + } + return list(grouped_accounts.values()) def _get_authority_aliases(self, instance): if not self.authority_groups: @@ -875,7 +1039,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( assert refresh_reason, "It should have been established at this point" try: result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( - authority, decorate_scope(scopes, self.client_id), account, + authority, self._decorate_scope(scopes), account, refresh_reason=refresh_reason, claims_challenge=claims_challenge, **kwargs)) if (result and "error" not in result) or (not access_token_from_cache): @@ -938,7 +1102,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token( # target=scopes, # AAD RTs are scope-independent query=query) logger.debug("Found %d RTs matching %s", len(matches), query) - client = self._build_client(self.client_credential, authority) + client, _ = self._build_client(self.client_credential, authority) response = None # A distinguishable value to mean cache is empty telemetry_context = self._build_telemetry_context( @@ -1021,7 +1185,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): refresh_reason=msal.telemetry.FORCE_REFRESH) response = _clean_up(self.client.obtain_token_by_refresh_token( refresh_token, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), rt_getter=lambda rt: rt, on_updating_rt=False, @@ -1052,7 +1216,7 @@ def acquire_token_by_username_password( - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - scopes = decorate_scope(scopes, self.client_id) + scopes = self._decorate_scope(scopes) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID) headers = telemetry_context.generate_headers() @@ -1113,7 +1277,13 @@ def _acquire_token_by_username_password_federated( self.client.grant_assertion_encoders.setdefault( # Register a non-standard type grant_type, self.client.encode_saml_assertion) return self.client.obtain_token_by_assertion( - wstrust_result["token"], grant_type, scope=scopes, **kwargs) + wstrust_result["token"], grant_type, scope=scopes, + on_obtaining_tokens=lambda event: self.token_cache.add(dict( + event, + environment=self.authority.instance, + username=username, # Useful in case IDT contains no such info + )), + **kwargs) class PublicClientApplication(ClientApplication): # browser app or mobile app @@ -1192,7 +1362,7 @@ def acquire_token_interactive( telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_INTERACTIVE) response = _clean_up(self.client.obtain_token_by_browser( - scope=decorate_scope(scopes, self.client_id) if scopes else None, + scope=self._decorate_scope(scopes) if scopes else None, extra_scope_to_consent=extra_scopes_to_consent, redirect_uri="http://localhost:{port}".format( # Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway @@ -1223,7 +1393,7 @@ def initiate_device_flow(self, scopes=None, **kwargs): """ correlation_id = msal.telemetry._get_new_correlation_id() flow = self.client.initiate_device_flow( - scope=decorate_scope(scopes or [], self.client_id), + scope=self._decorate_scope(scopes or []), headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id}, **kwargs) flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id @@ -1289,7 +1459,8 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_FOR_CLIENT_ID) - response = _clean_up(self.client.obtain_token_for_client( + client = self._regional_client or self.client + response = _clean_up(client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers=telemetry_context.generate_headers(), data=dict( @@ -1333,7 +1504,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 user_assertion, self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs - scope=decorate_scope(scopes, self.client_id), # Decoration is used for: + scope=self._decorate_scope(scopes), # Decoration is used for: # 1. Explicitly requesting an RT, without relying on AAD default # behavior, even though it currently still issues an RT. # 2. Requesting an IDT (which would otherwise be unavailable) diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 267bdc44..04a6b70d 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -770,7 +770,6 @@ def obtain_token_by_refresh_token(self, token_item, scope=None, rt_getter=lambda token_item: token_item["refresh_token"], on_removing_rt=None, on_updating_rt=None, - on_obtaining_tokens=None, **kwargs): # type: (Union[str, dict], Union[str, list, set, tuple], Callable) -> dict """This is an overload which will trigger token storage callbacks. diff --git a/msal/oauth2cli/oidc.py b/msal/oauth2cli/oidc.py index 75f23276..114693b1 100644 --- a/msal/oauth2cli/oidc.py +++ b/msal/oauth2cli/oidc.py @@ -83,6 +83,19 @@ def _nonce_hash(nonce): return hashlib.sha256(nonce.encode("ascii")).hexdigest() +class Prompt(object): + """This class defines the constant strings for prompt parameter. + + The values are based on + https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + NONE = "none" + LOGIN = "login" + CONSENT = "consent" + SELECT_ACCOUNT = "select_account" + CREATE = "create" # Defined in https://openid.net/specs/openid-connect-prompt-create-1_0.html#PromptParameter + + class Client(oauth2.Client): """OpenID Connect is a layer on top of the OAuth2. @@ -217,6 +230,8 @@ def obtain_token_by_browser( `OIDC `_. :param string prompt: Defined in `OIDC `_. + You can find the valid string values defined in :class:`oidc.Prompt`. + :param int max_age: Defined in `OIDC `_. :param string ui_locales: Defined in @@ -232,7 +247,7 @@ def obtain_token_by_browser( for descriptions on other parameters and return value. """ filtered_params = {k:v for k, v in dict( - prompt=prompt, + prompt=" ".join(prompt) if isinstance(prompt, (list, tuple)) else prompt, display=display, max_age=max_age, ui_locales=ui_locales, diff --git a/msal/region.py b/msal/region.py new file mode 100644 index 00000000..6ad84c45 --- /dev/null +++ b/msal/region.py @@ -0,0 +1,47 @@ +import os +import logging + +logger = logging.getLogger(__name__) + + +def _detect_region(http_client=None): + region = _detect_region_of_azure_function() # It is cheap, so we do it always + if http_client and not region: + return _detect_region_of_azure_vm(http_client) # It could hang for minutes + return region + + +def _detect_region_of_azure_function(): + return os.environ.get("REGION_NAME") + + +def _detect_region_of_azure_vm(http_client): + url = ( + "http://169.254.169.254/metadata/instance" + + # Utilize the "route parameters" feature to obtain region as a string + # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#route-parameters + "/compute/location?format=text" + + # Location info is available since API version 2017-04-02 + # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#response-1 + "&api-version=2021-01-01" + ) + logger.info( + "Connecting to IMDS {}. " + "It may take a while if you are running outside of Azure. " + "You should consider opting in/out region behavior on-demand, " + 'by loading a boolean flag "is_deployed_in_azure" ' + 'from your per-deployment config and then do ' + '"app = ConfidentialClientApplication(..., ' + 'azure_region=is_deployed_in_azure)"'.format(url)) + try: + # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#instance-metadata + resp = http_client.get(url, headers={"Metadata": "true"}) + except: + logger.info( + "IMDS {} unavailable. Perhaps not running in Azure VM?".format(url)) + return None + else: + return resp.text.strip() + diff --git a/msal/token_cache.py b/msal/token_cache.py index b0731278..5b31b299 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -108,11 +108,13 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info if sensitive in dictionary: dictionary[sensitive] = "********" wipe(event.get("data", {}), - ("password", "client_secret", "refresh_token", "assertion", "username")) + ("password", "client_secret", "refresh_token", "assertion")) try: return self.__add(event, now=now) finally: - wipe(event.get("response", {}), ("access_token", "refresh_token")) + wipe(event.get("response", {}), ( # These claims were useful during __add() + "access_token", "refresh_token", "id_token", "username")) + wipe(event, ["username"]) # Needed for federated ROPC logger.debug("event=%s", json.dumps( # We examined and concluded that this log won't have Log Injection risk, # because the event payload is already in JSON so CR/LF will be escaped. @@ -184,6 +186,8 @@ def __add(self, event, now=None): "oid", id_token_claims.get("sub")), "username": id_token_claims.get("preferred_username") # AAD or id_token_claims.get("upn") # ADFS 2019 + or data.get("username") # Falls back to ROPC username + or event.get("username") # Falls back to Federated ROPC username or "", # The schema does not like null "authority_type": self.AuthorityType.ADFS if realm == "adfs" diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index 6aafd160..b5f3950f 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -62,7 +62,9 @@ # after already extracting the username from an earlier sign-in # by using the preferred_username claim from returned id_token_claims. - #prompt="select_account", # Optional. It forces to show account selector page + #prompt=msal.Prompt.SELECT_ACCOUNT, # Or simply "select_account". Optional. It forces to show account selector page + #prompt=msal.Prompt.CREATE, # Or simply "create". Optional. It brings user to a self-service sign-up flow. + # Prerequisite: https://docs.microsoft.com/en-us/azure/active-directory/external-identities/self-service-sign-up-user-flow ) if "access_token" in result: diff --git a/setup.py b/setup.py index 0555c667..79bdda3e 100644 --- a/setup.py +++ b/setup.py @@ -58,11 +58,11 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], @@ -84,6 +84,7 @@ # We will go with "<4" for now, which is also what our another dependency, # pyjwt, currently use. + "mock;python_version<'3.3'", ] ) diff --git a/tests/test_application.py b/tests/test_application.py index f4787e2c..ea98b16f 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -555,3 +555,42 @@ def mock_post(url, headers=None, *args, **kwargs): result = self.app.acquire_token_on_behalf_of("assertion", ["s"], post=mock_post) self.assertEqual(at, result.get("access_token")) + +class TestClientApplicationWillGroupAccounts(unittest.TestCase): + def test_get_accounts(self): + client_id = "my_app" + scopes = ["scope_1", "scope_2"] + environment = "login.microsoftonline.com" + uid = "home_oid" + utid = "home_tenant_guid" + username = "Jane Doe" + cache = msal.SerializableTokenCache() + for tenant in ["contoso", "fabrikam"]: + cache.add({ + "client_id": client_id, + "scope": scopes, + "token_endpoint": + "https://{}/{}/oauth2/v2.0/token".format(environment, tenant), + "response": TokenCacheTestCase.build_response( + uid=uid, utid=utid, access_token="at", refresh_token="rt", + id_token=TokenCacheTestCase.build_id_token( + aud=client_id, + sub="oid_in_" + tenant, + preferred_username=username, + ), + ), + }) + app = ClientApplication( + client_id, + authority="https://{}/common".format(environment), + token_cache=cache) + accounts = app.get_accounts() + self.assertEqual(1, len(accounts), "Should return one grouped account") + account = accounts[0] + self.assertEqual("{}.{}".format(uid, utid), account["home_account_id"]) + self.assertEqual(environment, account["environment"]) + self.assertEqual(username, account["username"]) + self.assertIn("authority_type", account, "Backward compatibility") + self.assertIn("local_account_id", account, "Backward compatibility") + self.assertIn("realm", account, "Backward compatibility") + diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b9886257..b7280e8f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -4,11 +4,15 @@ import time import unittest import sys +try: + from unittest.mock import patch, ANY +except: + from mock import patch, ANY import requests import msal -from tests.http_client import MinimalHttpClient +from tests.http_client import MinimalHttpClient, MinimalResponse from msal.oauth2cli import AuthCodeReceiver logger = logging.getLogger(__name__) @@ -58,6 +62,12 @@ def assertLoosely(self, response, assertion=None, def assertCacheWorksForUser( self, result_from_wire, scope, username=None, data=None): + logger.debug( + "%s: cache = %s, id_token_claims = %s", + self.id(), + json.dumps(self.app.token_cache._cache, indent=4), + json.dumps(result_from_wire.get("id_token_claims"), indent=4), + ) # You can filter by predefined username, or let end user to choose one accounts = self.app.get_accounts(username=username) self.assertNotEqual(0, len(accounts)) @@ -76,10 +86,15 @@ def assertCacheWorksForUser( result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") - # Going to test acquire_token_silent(...) to obtain an AT by a RT from cache - self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs + if "refresh_token" in result_from_wire: + # Going to test acquire_token_silent(...) to obtain an AT by a RT from cache + self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs result_from_cache = self.app.acquire_token_silent( scope, account=account, data=data or {}) + if "refresh_token" not in result_from_wire: + self.assertEqual( + result_from_cache["access_token"], result_from_wire["access_token"], + "The previously cached AT should be returned") self.assertIsNotNone(result_from_cache, "We should get a result from acquire_token_silent(...) call") self.assertIsNotNone( @@ -93,6 +108,12 @@ def assertCacheWorksForUser( "We should get an AT from acquire_token_silent(...) call") def assertCacheWorksForApp(self, result_from_wire, scope): + logger.debug( + "%s: cache = %s, id_token_claims = %s", + self.id(), + json.dumps(self.app.token_cache._cache, indent=4), + json.dumps(result_from_wire.get("id_token_claims"), indent=4), + ) # Going to test acquire_token_silent(...) to locate an AT from cache result_from_cache = self.app.acquire_token_silent(scope, account=None) self.assertIsNotNone(result_from_cache) @@ -111,10 +132,9 @@ def _test_username_password(self, result = self.app.acquire_token_by_username_password( username, password, scopes=scope) self.assertLoosely(result) - # self.assertEqual(None, result.get("error"), str(result)) self.assertCacheWorksForUser( result, scope, - username=username if ".b2clogin.com" not in authority else None, + username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C ) def _test_device_flow( @@ -164,12 +184,6 @@ def _test_acquire_token_interactive( """.format(id=self.id(), username_uri=username_uri), data=data or {}, ) - logger.debug( - "%s: cache = %s, id_token_claims = %s", - self.id(), - json.dumps(self.app.token_cache._cache, indent=4), - json.dumps(result.get("id_token_claims"), indent=4), - ) self.assertIn( "access_token", result, "{error}: {error_description}".format( @@ -345,7 +359,10 @@ def test_device_flow(self): def get_lab_app( env_client_id="LAB_APP_CLIENT_ID", env_client_secret="LAB_APP_CLIENT_SECRET", - ): + authority="https://login.microsoftonline.com/" + "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID + timeout=None, + **kwargs): """Returns the lab app as an MSAL confidential client. Get it from environment variables if defined, otherwise fall back to use MSI. @@ -367,16 +384,21 @@ def get_lab_app( env_client_id, env_client_secret) # See also https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API's.aspx raise unittest.SkipTest("MSI-based mechanism has not been implemented yet") - return msal.ConfidentialClientApplication(client_id, client_secret, - authority="https://login.microsoftonline.com/" - "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID - http_client=MinimalHttpClient()) + return msal.ConfidentialClientApplication( + client_id, + client_credential=client_secret, + authority=authority, + http_client=MinimalHttpClient(timeout=timeout), + **kwargs) def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow logger.info("Creating session") - lab_token = lab_app.acquire_token_for_client(scopes) + result = lab_app.acquire_token_for_client(scopes) + assert result.get("access_token"), \ + "Unable to obtain token for lab. Encountered {}: {}".format( + result.get("error"), result.get("error_description")) session = requests.Session() - session.headers.update({"Authorization": "Bearer %s" % lab_token["access_token"]}) + session.headers.update({"Authorization": "Bearer %s" % result["access_token"]}) session.hooks["response"].append(lambda r, *args, **kwargs: r.raise_for_status()) return session @@ -401,7 +423,10 @@ def tearDownClass(cls): def get_lab_app_object(cls, **query): # https://msidlab.com/swagger/index.html url = "https://msidlab.com/api/app" resp = cls.session.get(url, params=query) - return resp.json()[0] + result = resp.json()[0] + result["scopes"] = [ # Raw data has extra space, such as "s1, s2" + s.strip() for s in result["defaultScopes"].split(',')] + return result @classmethod def get_lab_user_secret(cls, lab_name="msidlab4"): @@ -528,11 +553,13 @@ def _test_acquire_token_obo(self, config_pca, config_cca): # Assuming you already did that (which is not shown in this test case), # the following part shows one of the ways to obtain an AT from cache. username = cca_result.get("id_token_claims", {}).get("preferred_username") - self.assertEqual(config_cca["username"], username) - if username: # A precaution so that we won't use other user's token - account = cca.get_accounts(username=username)[0] - result = cca.acquire_token_silent(config_cca["scope"], account) - self.assertEqual(cca_result["access_token"], result["access_token"]) + if username: # It means CCA have requested an IDT w/ "profile" scope + self.assertEqual(config_cca["username"], username) + accounts = cca.get_accounts(username=username) + assert len(accounts) == 1, "App is expected to partition token cache per user" + account = accounts[0] + result = cca.acquire_token_silent(config_cca["scope"], account) + self.assertEqual(cca_result["access_token"], result["access_token"]) def _test_acquire_token_by_client_secret( self, client_id=None, client_secret=None, authority=None, scope=None, @@ -698,7 +725,7 @@ def test_b2c_acquire_token_by_auth_code(self): authority=self._build_b2c_authority("B2C_1_SignInPolicy"), client_id=config["appId"], port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] - scope=config["defaultScopes"].split(','), + scope=config["scopes"], ) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") @@ -708,7 +735,7 @@ def test_b2c_acquire_token_by_auth_code_flow(self): authority=self._build_b2c_authority("B2C_1_SignInPolicy"), client_id=config["appId"], port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] - scope=config["defaultScopes"].split(','), + scope=config["scopes"], username_uri="https://msidlab.com/api/user?usertype=b2c&b2cprovider=local", ) @@ -719,10 +746,96 @@ def test_b2c_acquire_token_by_ropc(self): client_id=config["appId"], username="b2clocal@msidlabb2c.onmicrosoft.com", password=self.get_lab_user_secret("msidlabb2c"), - scope=config["defaultScopes"].split(','), + scope=config["scopes"], ) +class WorldWideRegionalEndpointTestCase(LabBasedTestCase): + region = "westus" + + def test_acquire_token_for_client_should_hit_regional_endpoint(self): + """This is the only grant supported by regional endpoint, for now""" + self.app = get_lab_app( # Regional endpoint only supports confidential client + + ## FWIW, the MSAL<1.12 versions could use this to achieve similar result + #authority="https://westus.login.microsoft.com/microsoft.onmicrosoft.com", + #validate_authority=False, + authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", + azure_region=self.region, # Explicitly use this region, regardless of detection + + timeout=2, # Short timeout makes this test case responsive on non-VM + ) + scopes = ["https://graph.microsoft.com/.default"] + + with patch.object( # Test the request hit the regional endpoint + self.app.http_client, "post", return_value=MinimalResponse( + status_code=400, text='{"error": "mock"}')) as mocked_method: + self.app.acquire_token_for_client(scopes) + mocked_method.assert_called_with( + 'https://westus.login.microsoft.com/{}/oauth2/v2.0/token'.format( + self.app.authority.tenant), + params=ANY, data=ANY, headers=ANY) + result = self.app.acquire_token_for_client( + scopes, + params={"AllowEstsRNonMsi": "true"}, # For testing regional endpoint. It will be removed once MSAL Python 1.12+ has been onboard to ESTS-R + ) + self.assertIn('access_token', result) + self.assertCacheWorksForApp(result, scopes) + + +class RegionalEndpointViaEnvVarTestCase(WorldWideRegionalEndpointTestCase): + + def setUp(self): + os.environ["REGION_NAME"] = "eastus" + + def tearDown(self): + del os.environ["REGION_NAME"] + + @unittest.skipUnless( + os.getenv("LAB_OBO_CLIENT_SECRET"), + "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") + @unittest.skipUnless( + os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), + "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") + @unittest.skipUnless( + os.getenv("LAB_OBO_PUBLIC_CLIENT_ID"), + "Need LAB_OBO_PUBLIC_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") + def test_cca_obo_should_bypass_regional_endpoint_therefore_still_work(self): + """We test OBO because it is implemented in sub class ConfidentialClientApplication""" + config = self.get_lab_user(usertype="cloud") + + config_cca = {} + config_cca.update(config) + config_cca["client_id"] = os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID") + config_cca["scope"] = ["https://graph.microsoft.com/.default"] + config_cca["client_secret"] = os.getenv("LAB_OBO_CLIENT_SECRET") + + config_pca = {} + config_pca.update(config) + config_pca["client_id"] = os.getenv("LAB_OBO_PUBLIC_CLIENT_ID") + config_pca["password"] = self.get_lab_user_secret(config_pca["lab_name"]) + config_pca["scope"] = ["api://%s/read" % config_cca["client_id"]] + + self._test_acquire_token_obo(config_pca, config_cca) + + @unittest.skipUnless( + os.getenv("LAB_OBO_CLIENT_SECRET"), + "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") + @unittest.skipUnless( + os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), + "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") + def test_cca_ropc_should_bypass_regional_endpoint_therefore_still_work(self): + """We test ROPC because it is implemented in base class ClientApplication""" + config = self.get_lab_user(usertype="cloud") + config["password"] = self.get_lab_user_secret(config["lab_name"]) + # We repurpose the obo confidential app to test ROPC + # Swap in the OBO confidential app + config["client_id"] = os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID") + config["scope"] = ["https://graph.microsoft.com/.default"] + config["client_secret"] = os.getenv("LAB_OBO_CLIENT_SECRET") + self._test_username_password(**config) + + class ArlingtonCloudTestCase(LabBasedTestCase): environment = "azureusgovernment"