Skip to content

Commit 8ad210d

Browse files
committed
FMI Proof-of-concept
1 parent 5f55e7d commit 8ad210d

File tree

4 files changed

+86
-0
lines changed

4 files changed

+86
-0
lines changed

docs/fmi.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# FMI Flows
2+
3+
```mermaid
4+
sequenceDiagram
5+
title Legend: -------- FMI Cred ________ FMI Token
6+
autonumber
7+
participant RMA as Resource Manager Authority (RMA)
8+
participant sub-RMA as sub-RMA
9+
participant eSTS
10+
participant Resource
11+
12+
RMA-->>eSTS: client_id=rma_id<br>&client_assertion=SNI<br>&scope=api://AzureFMITokenExchange<br>&fmi_path=/eid1/c/<cloud>/t/<tenantid>/a/<rma>/<fmi_path><br>&...
13+
eSTS-->>RMA: Return FMI cred
14+
15+
Note over RMA, sub-RMA: Somehow transfer the FMI cred to sub-RMA
16+
17+
sub-RMA->>eSTS: client_id=urn:microsoft:identity:fmi<br>&client_assertion=FMI cred<br>&scope=api://a1b2c3...<br>&fmi_path=/eid1/c/<cloud>/t/<tenantid>/a/<rma>/<fmi_path><sub_path><br>&...
18+
eSTS->>sub-RMA: Return FMI token
19+
20+
sub-RMA->>Resource: Request with FMI token
21+
Resource->>sub-RMA: Access granted
22+
```

msal/application.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ class ClientApplication(object):
256256
_TOKEN_CACHE_DATA: dict[str, str] = { # field_in_data: field_in_cache
257257
"key_id": "key_id", # Some token types (SSH-certs, POP) are bound to a key
258258
"req_ds_cnf": "req_ds_cnf", # Used in CDT scenario
259+
"fmi_path": "fmi_path", # FMI artifacts are bound to their path
259260
}
260261

261262
@functools.lru_cache(maxsize=2)
@@ -2386,6 +2387,7 @@ def acquire_token_for_client(
23862387
delegation_constraints: Optional[list] = None,
23872388
delegation_confirmation_key=None, # A Cyprtography's RSAPrivateKey-like object
23882389
# TODO: Support ECC key? https://github.com/pyca/cryptography/issues/4093
2390+
fmi_path: Optional[str] = None,
23892391
**kwargs
23902392
):
23912393
"""Acquires token for the current confidential client, not for an end user.
@@ -2419,6 +2421,7 @@ def acquire_token_for_client(
24192421
kwargs.pop("data", {}),
24202422
req_ds_cnf=_build_req_cnf(jwk) # It is part of token cache key
24212423
if delegation_constraints else None,
2424+
fmi_path=fmi_path,
24222425
),
24232426
**kwargs))
24242427
if delegation_constraints and not result.get("error"):

msal/token_cache.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def __init__(self):
8484
# Note: New field(s) can be added here
8585
#key_id=None,
8686
req_ds_cnf=None,
87+
fmi_path=None,
8788
**ignored_payload_from_a_real_token:
8889
"-".join([ # Note: Could use a hash here to shorten key length
8990
home_account_id or "",
@@ -100,6 +101,7 @@ def __init__(self):
100101
# instead of response scope,
101102
# so that a search() can probably have O(1) hit.
102103
if req_ds_cnf else "", # CDT
104+
fmi_path or "", # See the TODO above
103105
]).lower(),
104106
self.CredentialType.ID_TOKEN:
105107
lambda home_account_id=None, environment=None, client_id=None,

tests/test_application.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,3 +931,62 @@ def test_acquire_token_for_client_should_return_a_cdt(self):
931931
self.assertAppObtainsCdt(app, ["scope1", "scope2"])
932932
self.assertEqual(mocked_post.call_count, 2)
933933

934+
935+
@patch("msal.authority.tenant_discovery", new=Mock(return_value={
936+
"authorization_endpoint": "https://contoso.com/placeholder",
937+
"token_endpoint": "https://contoso.com/placeholder",
938+
}))
939+
class FmiTestCase(unittest.TestCase):
940+
941+
def assertFmi(self, result: dict) -> None:
942+
self.assertIsNotNone(
943+
result.get("access_token"), "Encountered {}: {}".format(
944+
result.get("error"), result.get("error_description")))
945+
self.assertIn(result["token_type"], ("fmi_cred", "fmi_token"))
946+
947+
def assertAppObtainsAndCaches(self, app, scopes, fmi_path) -> dict:
948+
result = app.acquire_token_for_client(scopes, fmi_path=fmi_path)
949+
self.assertFmi(result)
950+
self.assertEqual(result["token_source"], "identity_provider")
951+
952+
result = app.acquire_token_for_client(scopes, fmi_path=fmi_path)
953+
self.assertFmi(result)
954+
self.assertEqual(result["token_source"], "cache")
955+
956+
return result
957+
958+
def assertAppCachesByFmipath(self, app, scopes, fmi_path) -> dict:
959+
with patch.object(app.http_client, "post", return_value=MinimalResponse(
960+
status_code=200, text=json.dumps({
961+
"token_type": "fmi_cred"
962+
if scopes == ["api://AzureFMITokenExchange"] else "fmi_token",
963+
"access_token": "payload",
964+
"expires_in": 3600,
965+
}))) as mocked_post:
966+
result = self.assertAppObtainsAndCaches(app, scopes, fmi_path)
967+
self.assertAppObtainsAndCaches(app, scopes, fmi_path + "/subpath")
968+
self.assertEqual(mocked_post.call_count, 2)
969+
self.assertEqual(
970+
2,
971+
len(list(app.token_cache.search(
972+
msal.TokenCache.CredentialType.ACCESS_TOKEN))),
973+
"Should cache both tokens")
974+
logger.debug("cache=%s", json.dumps(app.token_cache._cache, indent=2))
975+
return result
976+
977+
def test_fmi_with_rma_and_sub_rma(self):
978+
fmi_cred = self.assertAppCachesByFmipath(
979+
msal.ConfidentialClientApplication(
980+
"rma_client_id",
981+
client_credential={"client_assertion": "some assertion"},
982+
),
983+
["api://AzureFMITokenExchange"],
984+
"/eid1/c/<cloud>/t/<tenantid>/a/<rma>/<fmi_path>")
985+
self.assertAppCachesByFmipath(
986+
msal.ConfidentialClientApplication(
987+
"urn:microsoft:identity:fmi",
988+
client_credential={"client_assertion": fmi_cred["access_token"]},
989+
),
990+
["https://graph.microsoft.com/.default"],
991+
"/eid1/c/<cloud>/t/<tenantid>/a/<rma>/<fmi_path>/foo")
992+

0 commit comments

Comments
 (0)