Skip to content

Commit 0c15c75

Browse files
authored
Merge pull request #361 from AzureAD/remove-offline-access
Remove offline access
2 parents 0b84f5e + d6bf21e commit 0c15c75

File tree

3 files changed

+91
-49
lines changed

3 files changed

+91
-49
lines changed

msal/application.py

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,33 +27,6 @@
2727

2828
logger = logging.getLogger(__name__)
2929

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-
5730

5831
def extract_certs(public_cert_content):
5932
# Parses raw public certificate file contents and returns a list of strings
@@ -123,6 +96,7 @@ def __init__(
12396
# despite it is currently only needed by ConfidentialClientApplication.
12497
# This way, it holds the same positional param place for PCA,
12598
# when we would eventually want to add this feature to PCA in future.
99+
exclude_scopes=None,
126100
):
127101
"""Create an instance of application.
128102
@@ -275,11 +249,28 @@ def __init__(
275249
or provide a custom http_client which has a short timeout.
276250
That way, the latency would be under your control,
277251
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"]``.
278258
"""
279259
self.client_id = client_id
280260
self.client_credential = client_credential
281261
self.client_claims = client_claims
282262
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+
283274
if http_client:
284275
self.http_client = http_client
285276
else:
@@ -326,6 +317,34 @@ def __init__(
326317
self._telemetry_buffer = {}
327318
self._telemetry_lock = Lock()
328319

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+
329348
def _build_telemetry_context(
330349
self, api_id, correlation_id=None, refresh_reason=None):
331350
return msal.telemetry._TelemetryContext(
@@ -505,7 +524,7 @@ def initiate_auth_code_flow(
505524
flow = client.initiate_auth_code_flow(
506525
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
507526
prompt=prompt,
508-
scope=decorate_scope(scopes, self.client_id),
527+
scope=self._decorate_scope(scopes),
509528
domain_hint=domain_hint,
510529
claims=_merge_claims_challenge_and_capabilities(
511530
self._client_capabilities, claims_challenge),
@@ -587,7 +606,7 @@ def get_authorization_request_url(
587606
response_type=response_type,
588607
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
589608
prompt=prompt,
590-
scope=decorate_scope(scopes, self.client_id),
609+
scope=self._decorate_scope(scopes),
591610
nonce=nonce,
592611
domain_hint=domain_hint,
593612
claims=_merge_claims_challenge_and_capabilities(
@@ -650,7 +669,7 @@ def authorize(): # A controller in a web app
650669
response =_clean_up(self.client.obtain_token_by_auth_code_flow(
651670
auth_code_flow,
652671
auth_response,
653-
scope=decorate_scope(scopes, self.client_id) if scopes else None,
672+
scope=self._decorate_scope(scopes) if scopes else None,
654673
headers=telemetry_context.generate_headers(),
655674
data=dict(
656675
kwargs.pop("data", {}),
@@ -722,7 +741,7 @@ def acquire_token_by_authorization_code(
722741
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID)
723742
response = _clean_up(self.client.obtain_token_by_authorization_code(
724743
code, redirect_uri=redirect_uri,
725-
scope=decorate_scope(scopes, self.client_id),
744+
scope=self._decorate_scope(scopes),
726745
headers=telemetry_context.generate_headers(),
727746
data=dict(
728747
kwargs.pop("data", {}),
@@ -757,6 +776,13 @@ def get_accounts(self, username=None):
757776
lowercase_username = username.lower()
758777
accounts = [a for a in accounts
759778
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))
760786
# Does not further filter by existing RTs here. It probably won't matter.
761787
# Because in most cases Accounts and RTs co-exist.
762788
# 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(
10131039
assert refresh_reason, "It should have been established at this point"
10141040
try:
10151041
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,
10171043
refresh_reason=refresh_reason, claims_challenge=claims_challenge,
10181044
**kwargs))
10191045
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):
11591185
refresh_reason=msal.telemetry.FORCE_REFRESH)
11601186
response = _clean_up(self.client.obtain_token_by_refresh_token(
11611187
refresh_token,
1162-
scope=decorate_scope(scopes, self.client_id),
1188+
scope=self._decorate_scope(scopes),
11631189
headers=telemetry_context.generate_headers(),
11641190
rt_getter=lambda rt: rt,
11651191
on_updating_rt=False,
@@ -1190,7 +1216,7 @@ def acquire_token_by_username_password(
11901216
- A successful response would contain "access_token" key,
11911217
- an error response would contain "error" and usually "error_description".
11921218
"""
1193-
scopes = decorate_scope(scopes, self.client_id)
1219+
scopes = self._decorate_scope(scopes)
11941220
telemetry_context = self._build_telemetry_context(
11951221
self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID)
11961222
headers = telemetry_context.generate_headers()
@@ -1251,7 +1277,13 @@ def _acquire_token_by_username_password_federated(
12511277
self.client.grant_assertion_encoders.setdefault( # Register a non-standard type
12521278
grant_type, self.client.encode_saml_assertion)
12531279
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)
12551287

12561288

12571289
class PublicClientApplication(ClientApplication): # browser app or mobile app
@@ -1330,7 +1362,7 @@ def acquire_token_interactive(
13301362
telemetry_context = self._build_telemetry_context(
13311363
self.ACQUIRE_TOKEN_INTERACTIVE)
13321364
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,
13341366
extra_scope_to_consent=extra_scopes_to_consent,
13351367
redirect_uri="http://localhost:{port}".format(
13361368
# 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):
13611393
"""
13621394
correlation_id = msal.telemetry._get_new_correlation_id()
13631395
flow = self.client.initiate_device_flow(
1364-
scope=decorate_scope(scopes or [], self.client_id),
1396+
scope=self._decorate_scope(scopes or []),
13651397
headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id},
13661398
**kwargs)
13671399
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
14721504
response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
14731505
user_assertion,
14741506
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:
14761508
# 1. Explicitly requesting an RT, without relying on AAD default
14771509
# behavior, even though it currently still issues an RT.
14781510
# 2. Requesting an IDT (which would otherwise be unavailable)

msal/token_cache.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,13 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info
108108
if sensitive in dictionary:
109109
dictionary[sensitive] = "********"
110110
wipe(event.get("data", {}),
111-
("password", "client_secret", "refresh_token", "assertion", "username"))
111+
("password", "client_secret", "refresh_token", "assertion"))
112112
try:
113113
return self.__add(event, now=now)
114114
finally:
115-
wipe(event.get("response", {}), ("access_token", "refresh_token"))
115+
wipe(event.get("response", {}), ( # These claims were useful during __add()
116+
"access_token", "refresh_token", "username"))
117+
wipe(event, ["username"]) # Needed for federated ROPC
116118
logger.debug("event=%s", json.dumps(
117119
# We examined and concluded that this log won't have Log Injection risk,
118120
# because the event payload is already in JSON so CR/LF will be escaped.
@@ -184,6 +186,8 @@ def __add(self, event, now=None):
184186
"oid", id_token_claims.get("sub")),
185187
"username": id_token_claims.get("preferred_username") # AAD
186188
or id_token_claims.get("upn") # ADFS 2019
189+
or data.get("username") # Falls back to ROPC username
190+
or event.get("username") # Falls back to Federated ROPC username
187191
or "", # The schema does not like null
188192
"authority_type":
189193
self.AuthorityType.ADFS if realm == "adfs"

tests/test_e2e.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,15 @@ def assertCacheWorksForUser(
8686
result_from_wire['access_token'], result_from_cache['access_token'],
8787
"We should get a cached AT")
8888

89-
# Going to test acquire_token_silent(...) to obtain an AT by a RT from cache
90-
self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs
89+
if "refresh_token" in result_from_wire:
90+
# Going to test acquire_token_silent(...) to obtain an AT by a RT from cache
91+
self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs
9192
result_from_cache = self.app.acquire_token_silent(
9293
scope, account=account, data=data or {})
94+
if "refresh_token" not in result_from_wire:
95+
self.assertEqual(
96+
result_from_cache["access_token"], result_from_wire["access_token"],
97+
"The previously cached AT should be returned")
9398
self.assertIsNotNone(result_from_cache,
9499
"We should get a result from acquire_token_silent(...) call")
95100
self.assertIsNotNone(
@@ -127,10 +132,9 @@ def _test_username_password(self,
127132
result = self.app.acquire_token_by_username_password(
128133
username, password, scopes=scope)
129134
self.assertLoosely(result)
130-
# self.assertEqual(None, result.get("error"), str(result))
131135
self.assertCacheWorksForUser(
132136
result, scope,
133-
username=username if ".b2clogin.com" not in authority else None,
137+
username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C
134138
)
135139

136140
def _test_device_flow(
@@ -549,11 +553,13 @@ def _test_acquire_token_obo(self, config_pca, config_cca):
549553
# Assuming you already did that (which is not shown in this test case),
550554
# the following part shows one of the ways to obtain an AT from cache.
551555
username = cca_result.get("id_token_claims", {}).get("preferred_username")
552-
self.assertEqual(config_cca["username"], username)
553-
if username: # A precaution so that we won't use other user's token
554-
account = cca.get_accounts(username=username)[0]
555-
result = cca.acquire_token_silent(config_cca["scope"], account)
556-
self.assertEqual(cca_result["access_token"], result["access_token"])
556+
if username: # It means CCA have requested an IDT w/ "profile" scope
557+
self.assertEqual(config_cca["username"], username)
558+
accounts = cca.get_accounts(username=username)
559+
assert len(accounts) == 1, "App is expected to partition token cache per user"
560+
account = accounts[0]
561+
result = cca.acquire_token_silent(config_cca["scope"], account)
562+
self.assertEqual(cca_result["access_token"], result["access_token"])
557563

558564
def _test_acquire_token_by_client_secret(
559565
self, client_id=None, client_secret=None, authority=None, scope=None,

0 commit comments

Comments
 (0)