Skip to content

Commit 80f2397

Browse files
authored
Merge pull request #27 from AzureAD/foci
Family of Client Id (FoCI) support
2 parents 3419f87 + afa37b1 commit 80f2397

File tree

5 files changed

+203
-25
lines changed

5 files changed

+203
-25
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

msal/token_cache.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class CredentialType:
3030
REFRESH_TOKEN = "RefreshToken"
3131
ACCOUNT = "Account" # Not exactly a credential type, but we put it here
3232
ID_TOKEN = "IdToken"
33+
APP_METADATA = "AppMetadata"
3334

3435
class AuthorityType:
3536
ADFS = "ADFS"
@@ -162,6 +163,17 @@ def add(self, event, now=None):
162163
rt["family_id"] = response["foci"]
163164
self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {})[key] = rt
164165

166+
key = self._build_appmetadata_key(environment, event.get("client_id"))
167+
self._cache.setdefault(self.CredentialType.APP_METADATA, {})[key] = {
168+
"client_id": event.get("client_id"),
169+
"environment": environment,
170+
"family_id": response.get("foci"), # None is also valid
171+
}
172+
173+
@staticmethod
174+
def _build_appmetadata_key(environment, client_id):
175+
return "appmetadata-{}-{}".format(environment or "", client_id or "")
176+
165177
@classmethod
166178
def _build_rt_key(
167179
cls,

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+

tests/test_token_cache.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,57 @@
1212

1313
class TokenCacheTestCase(unittest.TestCase):
1414

15+
@staticmethod
16+
def build_id_token(sub="sub", oid="oid", preferred_username="me", **kwargs):
17+
return "header.%s.signature" % base64.b64encode(json.dumps(dict({
18+
"sub": sub,
19+
"oid": oid,
20+
"preferred_username": preferred_username,
21+
}, **kwargs)).encode()).decode('utf-8')
22+
23+
@staticmethod
24+
def build_response( # simulate a response from AAD
25+
uid="uid", utid="utid", # They will form client_info
26+
access_token=None, expires_in=3600, token_type="some type",
27+
refresh_token=None,
28+
foci=None,
29+
id_token=None, # or something generated by build_id_token()
30+
error=None,
31+
):
32+
response = {
33+
"client_info": base64.b64encode(json.dumps({
34+
"uid": uid, "utid": utid,
35+
}).encode()).decode('utf-8'),
36+
}
37+
if error:
38+
response["error"] = error
39+
if access_token:
40+
response.update({
41+
"access_token": access_token,
42+
"expires_in": expires_in,
43+
"token_type": token_type,
44+
})
45+
if refresh_token:
46+
response["refresh_token"] = refresh_token
47+
if id_token:
48+
response["id_token"] = id_token
49+
if foci:
50+
response["foci"] = foci
51+
return response
52+
1553
def setUp(self):
1654
self.cache = TokenCache()
1755

1856
def testAdd(self):
19-
client_info = base64.b64encode(b'''
20-
{"uid": "uid", "utid": "utid"}
21-
''').decode('utf-8')
22-
id_token = "header.%s.signature" % base64.b64encode(b'''{
23-
"sub": "subject",
24-
"oid": "object1234",
25-
"preferred_username": "John Doe"
26-
}''').decode('utf-8')
57+
id_token = self.build_id_token(oid="object1234", preferred_username="John Doe")
2758
self.cache.add({
2859
"client_id": "my_client_id",
2960
"scope": ["s2", "s1", "s3"], # Not in particular order
3061
"token_endpoint": "https://login.example.com/contoso/v2/token",
31-
"response": {
32-
"access_token": "an access token",
33-
"token_type": "some type",
34-
"expires_in": 3600,
35-
"refresh_token": "a refresh token",
36-
"client_info": client_info,
37-
"id_token": id_token,
38-
},
62+
"response": self.build_response(
63+
uid="uid", utid="utid", # client_info
64+
expires_in=3600, access_token="an access token",
65+
id_token=id_token, refresh_token="a refresh token"),
3966
}, now=1000)
4067
self.assertEqual(
4168
{
@@ -88,6 +115,15 @@ def testAdd(self):
88115
self.cache._cache["IdToken"].get(
89116
'uid.utid-login.example.com-idtoken-my_client_id-contoso-')
90117
)
118+
self.assertEqual(
119+
{
120+
"client_id": "my_client_id",
121+
'environment': 'login.example.com',
122+
"family_id": None,
123+
},
124+
self.cache._cache.get("AppMetadata", {}).get(
125+
"appmetadata-login.example.com-my_client_id")
126+
)
91127

92128

93129
class SerializableTokenCacheTestCase(TokenCacheTestCase):

0 commit comments

Comments
 (0)