Skip to content

Commit 08aa7fd

Browse files
authored
Add User Federated Identity Credential (user_fic) grant type support (#918)
* First draft of FIC support * Small fixes and feedback * PR feedback * PR feedback * Add integration tests * Adjust SNI behavior around .pfx files * Revert pfx changes and disable tests
1 parent d4f58ec commit 08aa7fd

6 files changed

Lines changed: 847 additions & 8 deletions

File tree

msal/application.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ class ClientApplication(object):
242242
ACQUIRE_TOKEN_FOR_CLIENT_ID = "730"
243243
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832"
244244
ACQUIRE_TOKEN_INTERACTIVE = "169"
245+
ACQUIRE_TOKEN_BY_USER_FIC_ID = "950"
245246
GET_ACCOUNTS_ID = "902"
246247
REMOVE_ACCOUNT_ID = "903"
247248

@@ -2572,3 +2573,68 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
25722573
response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
25732574
telemetry_context.update_telemetry(response)
25742575
return response
2576+
2577+
def acquire_token_by_user_federated_identity_credential(
2578+
self, scopes, assertion, username=None, user_object_id=None,
2579+
claims_challenge=None, **kwargs):
2580+
"""Acquires a user-scoped token using the ``user_fic`` grant type.
2581+
2582+
This method exchanges a federated identity credential (typically an
2583+
agent instance token from Leg 2 of the agent identity protocol) for
2584+
a user-scoped access token, enabling an agent to act on behalf of
2585+
a specific user.
2586+
2587+
:param list[str] scopes: Scopes required by downstream API (a resource).
2588+
:param str assertion:
2589+
The federated identity credential token (e.g. the instance token
2590+
obtained from Leg 2 of the agent identity flow).
2591+
:param str username:
2592+
The target user's UPN (User Principal Name).
2593+
Mutually exclusive with ``user_object_id``.
2594+
:param str user_object_id:
2595+
The target user's Object ID.
2596+
Mutually exclusive with ``username``.
2597+
:param claims_challenge:
2598+
The claims_challenge parameter requests specific claims requested by the resource provider
2599+
in the form of a claims_challenge directive in the www-authenticate header to be
2600+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
2601+
It is a string of a JSON object which contains lists of claims being requested from these locations.
2602+
2603+
:return: A dict representing the json response from Microsoft Entra:
2604+
2605+
- A successful response would contain "access_token" key,
2606+
- an error response would contain "error" and usually "error_description".
2607+
"""
2608+
# Input validation
2609+
if not assertion:
2610+
raise ValueError("assertion is required and must be non-empty")
2611+
if not username and not user_object_id:
2612+
raise ValueError(
2613+
"Either username or user_object_id must be provided")
2614+
if username and user_object_id:
2615+
raise ValueError(
2616+
"username and user_object_id are mutually exclusive")
2617+
2618+
telemetry_context = self._build_telemetry_context(
2619+
self.ACQUIRE_TOKEN_BY_USER_FIC_ID)
2620+
headers = telemetry_context.generate_headers()
2621+
if username:
2622+
headers["X-AnchorMailbox"] = "upn:{}".format(username)
2623+
elif user_object_id:
2624+
headers["X-AnchorMailbox"] = "Oid:{}@{}".format(
2625+
user_object_id, self.authority.tenant)
2626+
response = _clean_up(self.client.obtain_token_by_user_fic(
2627+
scope=self._decorate_scope(scopes),
2628+
assertion=assertion,
2629+
username=username,
2630+
user_object_id=user_object_id,
2631+
headers=headers,
2632+
data=dict(
2633+
kwargs.pop("data", {}),
2634+
claims=_merge_claims_challenge_and_capabilities(
2635+
self._client_capabilities, claims_challenge)),
2636+
**kwargs))
2637+
if "access_token" in response:
2638+
response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
2639+
telemetry_context.update_telemetry(response)
2640+
return response

msal/oauth2cli/oauth2.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
except ImportError:
88
from urlparse import parse_qs, urlparse, urlunparse
99
from urllib import urlencode, quote_plus
10+
import inspect
1011
import logging
1112
import warnings
1213
import time
@@ -104,6 +105,15 @@ def __init__(
104105
or a raw JWT assertion in bytes (which we will relay to http layer).
105106
It can also be a callable (recommended),
106107
so that we will do lazy creation of an assertion.
108+
109+
The callable may accept zero arguments (legacy) or one
110+
required positional argument. Callables whose positional
111+
parameters all have default values (e.g.
112+
``lambda token=token: token``) are treated as zero-arg.
113+
When the callable declares a required positional parameter,
114+
it will receive a dict containing ``"client_id"``,
115+
``"token_endpoint"``, and optionally ``"fmi_path"``
116+
(when an FMI path is set on the current request).
107117
client_assertion_type (str):
108118
The type of your :attr:`client_assertion` parameter.
109119
It is typically the value of :attr:`CLIENT_ASSERTION_TYPE_SAML2` or
@@ -168,6 +178,41 @@ def __init__(
168178
# A workaround for requests not supporting session-wide timeout
169179
self._http_client.request, timeout=timeout)
170180

181+
@staticmethod
182+
def _accepts_context(func):
183+
"""Check if a callable requires at least one positional argument.
184+
185+
Returns True only when the callable has a positional parameter
186+
**without** a default value. This ensures that legacy zero-arg
187+
callables — including ``lambda token=token: token`` patterns
188+
where every positional param has a default — are still invoked
189+
with no arguments.
190+
"""
191+
try:
192+
sig = inspect.signature(func)
193+
for p in sig.parameters.values():
194+
if p.kind in (
195+
inspect.Parameter.POSITIONAL_ONLY,
196+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
197+
) and p.default is inspect.Parameter.empty:
198+
return True
199+
return False
200+
except (ValueError, TypeError):
201+
return False # Signature not inspectable; treat as zero-arg
202+
203+
def _invoke_assertion_callable(self, assertion_callable, data=None):
204+
"""Invoke an assertion callable, passing context if it accepts one."""
205+
if self._accepts_context(assertion_callable):
206+
context = {
207+
"client_id": self.client_id,
208+
"token_endpoint": self.configuration.get(
209+
"token_endpoint", ""),
210+
}
211+
if data and data.get("fmi_path"):
212+
context["fmi_path"] = data["fmi_path"]
213+
return assertion_callable(context)
214+
return assertion_callable()
215+
171216
def _build_auth_request_params(self, response_type, **kwargs):
172217
# response_type is a string defined in
173218
# https://tools.ietf.org/html/rfc6749#section-3.1.1
@@ -198,11 +243,11 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749
198243
# See https://tools.ietf.org/html/rfc7521#section-4.2
199244
encoder = self.client_assertion_encoders.get(
200245
self.default_body["client_assertion_type"], lambda a: a)
201-
_data["client_assertion"] = encoder(
202-
self.client_assertion() # Do lazy on-the-fly computation
203-
if callable(self.client_assertion) else self.client_assertion
204-
) # The type is bytes, which is preferable. See also:
205-
# https://github.com/psf/requests/issues/4503#issuecomment-455001070
246+
if callable(self.client_assertion):
247+
raw = self._invoke_assertion_callable(self.client_assertion, data)
248+
else:
249+
raw = self.client_assertion
250+
_data["client_assertion"] = encoder(raw)
206251

207252
_data.update(self.default_body) # It may contain authen parameters
208253
_data.update(data or {}) # So the content in data param prevails
@@ -770,6 +815,34 @@ class initialization.
770815
data.update(scope=scope)
771816
return self._obtain_token("client_credentials", data=data, **kwargs)
772817

818+
def obtain_token_by_user_fic(
819+
self, scope, assertion, username=None, user_object_id=None,
820+
**kwargs):
821+
"""Obtain token using the ``user_fic`` grant type.
822+
823+
This exchanges a federated identity credential (e.g. an agent
824+
instance token) for a user-scoped access token.
825+
826+
:param scope: Scopes for the target resource (already decorated
827+
with OIDC scopes by the caller).
828+
:param str assertion: The federated identity credential token.
829+
:param str username: The target user's UPN (mutually exclusive
830+
with *user_object_id*).
831+
:param str user_object_id: The target user's Object ID (mutually
832+
exclusive with *username*).
833+
"""
834+
data = kwargs.pop("data", {})
835+
data.update(
836+
scope=scope,
837+
user_federated_identity_credential=assertion,
838+
client_info="1",
839+
)
840+
if user_object_id:
841+
data["user_id"] = str(user_object_id)
842+
elif username:
843+
data["username"] = username
844+
return self._obtain_token("user_fic", data=data, **kwargs)
845+
773846
def __init__(self,
774847
server_configuration, client_id,
775848
on_obtaining_tokens=lambda event: None, # event is defined in _obtain_token(...)

msal/throttled_http_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ def __init__(self, *args, default_throttle_time=None, **kwargs):
126126
# TODO: We may want to disable it for confidential client, though
127127
_extract_data(kwargs, "refresh_token", # "account" during refresh
128128
_extract_data(kwargs, "code", # "account" of auth code grant
129-
_extract_data(kwargs, "username")))), # "account" of ROPC
129+
_extract_data(kwargs, "username", # "account" of ROPC
130+
_extract_data(kwargs, "user_id"))))), # "account" of user_fic (OID path)
130131
),
131132
expires_in=RetryAfterParser(default_throttle_time or 5).parse,
132133
)(self.post)

msal/token_cache.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
"token_type",
6666
"req_cnf",
6767
"key_id",
68+
# user_fic grant parameters — these are standard body params for the
69+
# user_fic flow; FIC tokens use normal user cache keys (not extended).
70+
"user_federated_identity_credential",
71+
"user_id",
72+
"client_info",
6873
})
6974

7075

@@ -301,6 +306,7 @@ def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info
301306
event,
302307
data=make_clean_copy(event.get("data", {}), (
303308
"password", "client_secret", "refresh_token", "assertion",
309+
"user_federated_identity_credential",
304310
)),
305311
response=make_clean_copy(event.get("response", {}), (
306312
"id_token_claims", # Provided by broker
@@ -410,7 +416,7 @@ def __add(self, event, now=None):
410416
}
411417
grant_types_that_establish_an_account = (
412418
_GRANT_TYPE_BROKER, "authorization_code", "password",
413-
Client.DEVICE_FLOW["GRANT_TYPE"])
419+
Client.DEVICE_FLOW["GRANT_TYPE"], "user_fic")
414420
if event.get("grant_type") in grant_types_that_establish_an_account:
415421
account["account_source"] = event["grant_type"]
416422
self.modify(self.CredentialType.ACCOUNT, account, account)

0 commit comments

Comments
 (0)