|
27 | 27 |
|
28 | 28 | logger = logging.getLogger(__name__)
|
29 | 29 |
|
30 |
| -def decorate_scope( |
31 |
| - scopes, client_id, |
32 |
| - reserved_scope=frozenset(['openid', 'profile', 'offline_access'])): |
33 |
| - if not isinstance(scopes, (list, set, tuple)): |
34 |
| - raise ValueError("The input scopes should be a list, tuple, or set") |
35 |
| - scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set. |
36 |
| - if scope_set & reserved_scope: |
37 |
| - # These scopes are reserved for the API to provide good experience. |
38 |
| - # We could make the developer pass these and then if they do they will |
39 |
| - # come back asking why they don't see refresh token or user information. |
40 |
| - raise ValueError( |
41 |
| - "API does not accept {} value as user-provided scopes".format( |
42 |
| - reserved_scope)) |
43 |
| - if client_id in scope_set: |
44 |
| - if len(scope_set) > 1: |
45 |
| - # We make developers pass their client id, so that they can express |
46 |
| - # the intent that they want the token for themselves (their own |
47 |
| - # app). |
48 |
| - # If we do not restrict them to passing only client id then they |
49 |
| - # could write code where they expect an id token but end up getting |
50 |
| - # access_token. |
51 |
| - raise ValueError("Client Id can only be provided as a single scope") |
52 |
| - decorated = set(reserved_scope) # Make a writable copy |
53 |
| - else: |
54 |
| - decorated = scope_set | reserved_scope |
55 |
| - return list(decorated) |
56 |
| - |
57 | 30 |
|
58 | 31 | def extract_certs(public_cert_content):
|
59 | 32 | # Parses raw public certificate file contents and returns a list of strings
|
@@ -123,6 +96,7 @@ def __init__(
|
123 | 96 | # despite it is currently only needed by ConfidentialClientApplication.
|
124 | 97 | # This way, it holds the same positional param place for PCA,
|
125 | 98 | # when we would eventually want to add this feature to PCA in future.
|
| 99 | + exclude_scopes=None, |
126 | 100 | ):
|
127 | 101 | """Create an instance of application.
|
128 | 102 |
|
@@ -275,11 +249,28 @@ def __init__(
|
275 | 249 | or provide a custom http_client which has a short timeout.
|
276 | 250 | That way, the latency would be under your control,
|
277 | 251 | but still less performant than opting out of region feature.
|
| 252 | + :param list[str] exclude_scopes: (optional) |
| 253 | + Historically MSAL hardcodes `offline_access` scope, |
| 254 | + which would allow your app to have prolonged access to user's data. |
| 255 | + If that is unnecessary or undesirable for your app, |
| 256 | + now you can use this parameter to supply an exclusion list of scopes, |
| 257 | + such as ``exclude_scopes = ["offline_access"]``. |
278 | 258 | """
|
279 | 259 | self.client_id = client_id
|
280 | 260 | self.client_credential = client_credential
|
281 | 261 | self.client_claims = client_claims
|
282 | 262 | self._client_capabilities = client_capabilities
|
| 263 | + |
| 264 | + if exclude_scopes and not isinstance(exclude_scopes, list): |
| 265 | + raise ValueError( |
| 266 | + "Invalid exclude_scopes={}. It need to be a list of strings.".format( |
| 267 | + repr(exclude_scopes))) |
| 268 | + self._exclude_scopes = frozenset(exclude_scopes or []) |
| 269 | + if "openid" in self._exclude_scopes: |
| 270 | + raise ValueError( |
| 271 | + 'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format( |
| 272 | + repr(exclude_scopes))) |
| 273 | + |
283 | 274 | if http_client:
|
284 | 275 | self.http_client = http_client
|
285 | 276 | else:
|
@@ -326,6 +317,34 @@ def __init__(
|
326 | 317 | self._telemetry_buffer = {}
|
327 | 318 | self._telemetry_lock = Lock()
|
328 | 319 |
|
| 320 | + def _decorate_scope( |
| 321 | + self, scopes, |
| 322 | + reserved_scope=frozenset(['openid', 'profile', 'offline_access'])): |
| 323 | + if not isinstance(scopes, (list, set, tuple)): |
| 324 | + raise ValueError("The input scopes should be a list, tuple, or set") |
| 325 | + scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set. |
| 326 | + if scope_set & reserved_scope: |
| 327 | + # These scopes are reserved for the API to provide good experience. |
| 328 | + # We could make the developer pass these and then if they do they will |
| 329 | + # come back asking why they don't see refresh token or user information. |
| 330 | + raise ValueError( |
| 331 | + "API does not accept {} value as user-provided scopes".format( |
| 332 | + reserved_scope)) |
| 333 | + if self.client_id in scope_set: |
| 334 | + if len(scope_set) > 1: |
| 335 | + # We make developers pass their client id, so that they can express |
| 336 | + # the intent that they want the token for themselves (their own |
| 337 | + # app). |
| 338 | + # If we do not restrict them to passing only client id then they |
| 339 | + # could write code where they expect an id token but end up getting |
| 340 | + # access_token. |
| 341 | + raise ValueError("Client Id can only be provided as a single scope") |
| 342 | + decorated = set(reserved_scope) # Make a writable copy |
| 343 | + else: |
| 344 | + decorated = scope_set | reserved_scope |
| 345 | + decorated -= self._exclude_scopes |
| 346 | + return list(decorated) |
| 347 | + |
329 | 348 | def _build_telemetry_context(
|
330 | 349 | self, api_id, correlation_id=None, refresh_reason=None):
|
331 | 350 | return msal.telemetry._TelemetryContext(
|
@@ -505,7 +524,7 @@ def initiate_auth_code_flow(
|
505 | 524 | flow = client.initiate_auth_code_flow(
|
506 | 525 | redirect_uri=redirect_uri, state=state, login_hint=login_hint,
|
507 | 526 | prompt=prompt,
|
508 |
| - scope=decorate_scope(scopes, self.client_id), |
| 527 | + scope=self._decorate_scope(scopes), |
509 | 528 | domain_hint=domain_hint,
|
510 | 529 | claims=_merge_claims_challenge_and_capabilities(
|
511 | 530 | self._client_capabilities, claims_challenge),
|
@@ -587,7 +606,7 @@ def get_authorization_request_url(
|
587 | 606 | response_type=response_type,
|
588 | 607 | redirect_uri=redirect_uri, state=state, login_hint=login_hint,
|
589 | 608 | prompt=prompt,
|
590 |
| - scope=decorate_scope(scopes, self.client_id), |
| 609 | + scope=self._decorate_scope(scopes), |
591 | 610 | nonce=nonce,
|
592 | 611 | domain_hint=domain_hint,
|
593 | 612 | claims=_merge_claims_challenge_and_capabilities(
|
@@ -650,7 +669,7 @@ def authorize(): # A controller in a web app
|
650 | 669 | response =_clean_up(self.client.obtain_token_by_auth_code_flow(
|
651 | 670 | auth_code_flow,
|
652 | 671 | auth_response,
|
653 |
| - scope=decorate_scope(scopes, self.client_id) if scopes else None, |
| 672 | + scope=self._decorate_scope(scopes) if scopes else None, |
654 | 673 | headers=telemetry_context.generate_headers(),
|
655 | 674 | data=dict(
|
656 | 675 | kwargs.pop("data", {}),
|
@@ -722,7 +741,7 @@ def acquire_token_by_authorization_code(
|
722 | 741 | self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID)
|
723 | 742 | response = _clean_up(self.client.obtain_token_by_authorization_code(
|
724 | 743 | code, redirect_uri=redirect_uri,
|
725 |
| - scope=decorate_scope(scopes, self.client_id), |
| 744 | + scope=self._decorate_scope(scopes), |
726 | 745 | headers=telemetry_context.generate_headers(),
|
727 | 746 | data=dict(
|
728 | 747 | kwargs.pop("data", {}),
|
@@ -757,6 +776,13 @@ def get_accounts(self, username=None):
|
757 | 776 | lowercase_username = username.lower()
|
758 | 777 | accounts = [a for a in accounts
|
759 | 778 | if a["username"].lower() == lowercase_username]
|
| 779 | + if not accounts: |
| 780 | + logger.warning(( |
| 781 | + "get_accounts(username='{}') finds no account. " |
| 782 | + "If tokens were acquired without 'profile' scope, " |
| 783 | + "they would contain no username for filtering. " |
| 784 | + "Consider calling get_accounts(username=None) instead." |
| 785 | + ).format(username)) |
760 | 786 | # Does not further filter by existing RTs here. It probably won't matter.
|
761 | 787 | # Because in most cases Accounts and RTs co-exist.
|
762 | 788 | # Even in the rare case when an RT is revoked and then removed,
|
@@ -1013,7 +1039,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
|
1013 | 1039 | assert refresh_reason, "It should have been established at this point"
|
1014 | 1040 | try:
|
1015 | 1041 | result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
|
1016 |
| - authority, decorate_scope(scopes, self.client_id), account, |
| 1042 | + authority, self._decorate_scope(scopes), account, |
1017 | 1043 | refresh_reason=refresh_reason, claims_challenge=claims_challenge,
|
1018 | 1044 | **kwargs))
|
1019 | 1045 | if (result and "error" not in result) or (not access_token_from_cache):
|
@@ -1159,7 +1185,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
|
1159 | 1185 | refresh_reason=msal.telemetry.FORCE_REFRESH)
|
1160 | 1186 | response = _clean_up(self.client.obtain_token_by_refresh_token(
|
1161 | 1187 | refresh_token,
|
1162 |
| - scope=decorate_scope(scopes, self.client_id), |
| 1188 | + scope=self._decorate_scope(scopes), |
1163 | 1189 | headers=telemetry_context.generate_headers(),
|
1164 | 1190 | rt_getter=lambda rt: rt,
|
1165 | 1191 | on_updating_rt=False,
|
@@ -1190,7 +1216,7 @@ def acquire_token_by_username_password(
|
1190 | 1216 | - A successful response would contain "access_token" key,
|
1191 | 1217 | - an error response would contain "error" and usually "error_description".
|
1192 | 1218 | """
|
1193 |
| - scopes = decorate_scope(scopes, self.client_id) |
| 1219 | + scopes = self._decorate_scope(scopes) |
1194 | 1220 | telemetry_context = self._build_telemetry_context(
|
1195 | 1221 | self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID)
|
1196 | 1222 | headers = telemetry_context.generate_headers()
|
@@ -1251,7 +1277,13 @@ def _acquire_token_by_username_password_federated(
|
1251 | 1277 | self.client.grant_assertion_encoders.setdefault( # Register a non-standard type
|
1252 | 1278 | grant_type, self.client.encode_saml_assertion)
|
1253 | 1279 | return self.client.obtain_token_by_assertion(
|
1254 |
| - wstrust_result["token"], grant_type, scope=scopes, **kwargs) |
| 1280 | + wstrust_result["token"], grant_type, scope=scopes, |
| 1281 | + on_obtaining_tokens=lambda event: self.token_cache.add(dict( |
| 1282 | + event, |
| 1283 | + environment=self.authority.instance, |
| 1284 | + username=username, # Useful in case IDT contains no such info |
| 1285 | + )), |
| 1286 | + **kwargs) |
1255 | 1287 |
|
1256 | 1288 |
|
1257 | 1289 | class PublicClientApplication(ClientApplication): # browser app or mobile app
|
@@ -1330,7 +1362,7 @@ def acquire_token_interactive(
|
1330 | 1362 | telemetry_context = self._build_telemetry_context(
|
1331 | 1363 | self.ACQUIRE_TOKEN_INTERACTIVE)
|
1332 | 1364 | response = _clean_up(self.client.obtain_token_by_browser(
|
1333 |
| - scope=decorate_scope(scopes, self.client_id) if scopes else None, |
| 1365 | + scope=self._decorate_scope(scopes) if scopes else None, |
1334 | 1366 | extra_scope_to_consent=extra_scopes_to_consent,
|
1335 | 1367 | redirect_uri="http://localhost:{port}".format(
|
1336 | 1368 | # Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway
|
@@ -1361,7 +1393,7 @@ def initiate_device_flow(self, scopes=None, **kwargs):
|
1361 | 1393 | """
|
1362 | 1394 | correlation_id = msal.telemetry._get_new_correlation_id()
|
1363 | 1395 | flow = self.client.initiate_device_flow(
|
1364 |
| - scope=decorate_scope(scopes or [], self.client_id), |
| 1396 | + scope=self._decorate_scope(scopes or []), |
1365 | 1397 | headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id},
|
1366 | 1398 | **kwargs)
|
1367 | 1399 | flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id
|
@@ -1472,7 +1504,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
|
1472 | 1504 | response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
|
1473 | 1505 | user_assertion,
|
1474 | 1506 | self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs
|
1475 |
| - scope=decorate_scope(scopes, self.client_id), # Decoration is used for: |
| 1507 | + scope=self._decorate_scope(scopes), # Decoration is used for: |
1476 | 1508 | # 1. Explicitly requesting an RT, without relying on AAD default
|
1477 | 1509 | # behavior, even though it currently still issues an RT.
|
1478 | 1510 | # 2. Requesting an IDT (which would otherwise be unavailable)
|
|
0 commit comments