Skip to content

Commit afa37b1

Browse files
committed
FOCI Single Sign On
1 parent 56d8dde commit afa37b1

File tree

3 files changed

+139
-9
lines changed

3 files changed

+139
-9
lines changed

msal/application.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -305,26 +305,71 @@ def acquire_token_silent(
305305
"token_type": "Bearer",
306306
"expires_in": int(expires_in), # OAuth2 specs defines it as int
307307
}
308+
return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
309+
the_authority, decorate_scope(scopes, self.client_id), account,
310+
**kwargs)
308311

312+
def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
313+
self, authority, scopes, account, **kwargs):
314+
query = {
315+
"environment": authority.instance,
316+
"home_account_id": (account or {}).get("home_account_id"),
317+
# "realm": authority.tenant, # AAD RTs are tenant-independent
318+
}
319+
apps = self.token_cache.find( # Use find(), rather than token_cache.get(...)
320+
TokenCache.CredentialType.APP_METADATA, query={
321+
"environment": authority.instance, "client_id": self.client_id})
322+
app_metadata = apps[0] if apps else {}
323+
if not app_metadata: # Meaning this app is now used for the first time.
324+
# When/if we have a way to directly detect current app's family,
325+
# we'll rewrite this block, to support multiple families.
326+
# For now, we try existing RTs (*). If it works, we are in that family.
327+
# (*) RTs of a different app/family are not supposed to be
328+
# shared with or accessible by us in the first place.
329+
at = self._acquire_token_silent_by_finding_specific_refresh_token(
330+
authority, scopes,
331+
dict(query, family_id="1"), # A hack, we have only 1 family for now
332+
rt_remover=lambda rt_item: None, # NO-OP b/c RTs are likely not mine
333+
break_condition=lambda response: # Break loop when app not in family
334+
# Based on an AAD-only behavior mentioned in internal doc here
335+
# https://msazure.visualstudio.com/One/_git/ESTS-Docs/pullrequest/1138595
336+
"client_mismatch" in response.get("error_additional_info", []),
337+
**kwargs)
338+
if at:
339+
return at
340+
if app_metadata.get("family_id"): # Meaning this app belongs to this family
341+
at = self._acquire_token_silent_by_finding_specific_refresh_token(
342+
authority, scopes, dict(query, family_id=app_metadata["family_id"]),
343+
**kwargs)
344+
if at:
345+
return at
346+
# Either this app is an orphan, so we will naturally use its own RT;
347+
# or all attempts above have failed, so we fall back to non-foci behavior.
348+
return self._acquire_token_silent_by_finding_specific_refresh_token(
349+
authority, scopes, dict(query, client_id=self.client_id), **kwargs)
350+
351+
def _acquire_token_silent_by_finding_specific_refresh_token(
352+
self, authority, scopes, query,
353+
rt_remover=None, break_condition=lambda response: False, **kwargs):
309354
matches = self.token_cache.find(
310355
self.token_cache.CredentialType.REFRESH_TOKEN,
311356
# target=scopes, # AAD RTs are scope-independent
312-
query={
313-
"client_id": self.client_id,
314-
"environment": the_authority.instance,
315-
"home_account_id": (account or {}).get("home_account_id"),
316-
# "realm": the_authority.tenant, # AAD RTs are tenant-independent
317-
})
318-
client = self._build_client(self.client_credential, the_authority)
357+
query=query)
358+
logger.debug("Found %d RTs matching %s", len(matches), query)
359+
client = self._build_client(self.client_credential, authority)
319360
for entry in matches:
320-
logger.debug("Cache hit an RT")
361+
logger.debug("Cache attempts an RT")
321362
response = client.obtain_token_by_refresh_token(
322363
entry, rt_getter=lambda token_item: token_item["secret"],
323-
scope=decorate_scope(scopes, self.client_id))
364+
on_removing_rt=rt_remover or self.token_cache.remove_rt,
365+
scope=scopes,
366+
**kwargs)
324367
if "error" not in response:
325368
return response
326369
logger.debug(
327370
"Refresh failed. {error}: {error_description}".format(**response))
371+
if break_condition(response):
372+
break
328373

329374

330375
class PublicClientApplication(ClientApplication): # browser app or mobile app

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
.
2+
mock; python_version < '3.3'

tests/test_application.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@
22
import json
33
import logging
44

5+
try:
6+
from unittest.mock import * # Python 3
7+
except:
8+
from mock import * # Need an external mock package
9+
510
from msal.application import *
11+
import msal
612
from tests import unittest
13+
from tests.test_token_cache import TokenCacheTestCase
714

815

916
THIS_FOLDER = os.path.dirname(__file__)
@@ -155,3 +162,80 @@ def test_auth_code(self):
155162
error_description=result.get("error_description")))
156163
self.assertCacheWorks(result)
157164

165+
166+
class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase):
167+
168+
def setUp(self):
169+
self.authority_url = "https://login.microsoftonline.com/common"
170+
self.authority = msal.authority.Authority(self.authority_url)
171+
self.scopes = ["s1", "s2"]
172+
self.uid = "my_uid"
173+
self.utid = "my_utid"
174+
self.account = {"home_account_id": "{}.{}".format(self.uid, self.utid)}
175+
self.frt = "what the frt"
176+
self.cache = msal.SerializableTokenCache()
177+
self.cache.add({ # Pre-populate a FRT
178+
"client_id": "preexisting_family_app",
179+
"scope": self.scopes,
180+
"token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url),
181+
"response": TokenCacheTestCase.build_response(
182+
uid=self.uid, utid=self.utid, refresh_token=self.frt, foci="1"),
183+
}) # The add(...) helper populates correct home_account_id for future searching
184+
185+
def test_unknown_orphan_app_will_attempt_frt_and_not_remove_it(self):
186+
app = ClientApplication(
187+
"unknown_orphan", authority=self.authority_url, token_cache=self.cache)
188+
logger.debug("%s.cache = %s", self.id(), self.cache.serialize())
189+
def tester(url, data=None, **kwargs):
190+
self.assertEqual(self.frt, data.get("refresh_token"), "Should attempt the FRT")
191+
return Mock(status_code=200, json=Mock(return_value={
192+
"error": "invalid_grant",
193+
"error_description": "Was issued to another client"}))
194+
app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
195+
self.authority, self.scopes, self.account, post=tester)
196+
self.assertNotEqual([], app.token_cache.find(
197+
msal.TokenCache.CredentialType.REFRESH_TOKEN, query={"secret": self.frt}),
198+
"The FRT should not be removed from the cache")
199+
200+
def test_known_orphan_app_will_skip_frt_and_only_use_its_own_rt(self):
201+
app = ClientApplication(
202+
"known_orphan", authority=self.authority_url, token_cache=self.cache)
203+
rt = "RT for this orphan app. We will check it being used by this test case."
204+
self.cache.add({ # Populate its RT and AppMetadata, so it becomes a known orphan app
205+
"client_id": app.client_id,
206+
"scope": self.scopes,
207+
"token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url),
208+
"response": TokenCacheTestCase.build_response(
209+
uid=self.uid, utid=self.utid, refresh_token=rt),
210+
})
211+
logger.debug("%s.cache = %s", self.id(), self.cache.serialize())
212+
def tester(url, data=None, **kwargs):
213+
self.assertEqual(rt, data.get("refresh_token"), "Should attempt the RT")
214+
return Mock(status_code=200, json=Mock(return_value={}))
215+
app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
216+
self.authority, self.scopes, self.account, post=tester)
217+
218+
def test_unknown_family_app_will_attempt_frt_and_join_family(self):
219+
def tester(url, data=None, **kwargs):
220+
self.assertEqual(
221+
self.frt, data.get("refresh_token"), "Should attempt the FRT")
222+
return Mock(
223+
status_code=200,
224+
json=Mock(return_value=TokenCacheTestCase.build_response(
225+
uid=self.uid, utid=self.utid, foci="1", access_token="at")))
226+
app = ClientApplication(
227+
"unknown_family_app", authority=self.authority_url, token_cache=self.cache)
228+
at = app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
229+
self.authority, self.scopes, self.account, post=tester)
230+
logger.debug("%s.cache = %s", self.id(), self.cache.serialize())
231+
self.assertEqual("at", at.get("access_token"), "New app should get a new AT")
232+
app_metadata = app.token_cache.find(
233+
msal.TokenCache.CredentialType.APP_METADATA,
234+
query={"client_id": app.client_id})
235+
self.assertNotEqual([], app_metadata, "Should record new app's metadata")
236+
self.assertEqual("1", app_metadata[0].get("family_id"),
237+
"The new family app should be recorded as in the same family")
238+
# Known family app will simply use FRT, which is largely the same as this one
239+
240+
# Will not test scenario of app leaving family. Per specs, it won't happen.
241+

0 commit comments

Comments
 (0)