Skip to content

Commit 079fbdb

Browse files
committed
First draft of FIC support
1 parent 895b581 commit 079fbdb

4 files changed

Lines changed: 435 additions & 6 deletions

File tree

msal/application.py

Lines changed: 60 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,62 @@ 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+
response = _clean_up(self.client.obtain_token_by_user_fic(
2621+
scope=self._decorate_scope(scopes),
2622+
assertion=assertion,
2623+
username=username,
2624+
user_object_id=user_object_id,
2625+
headers=telemetry_context.generate_headers(),
2626+
data=dict(
2627+
kwargs.pop("data", {}),
2628+
claims=_merge_claims_challenge_and_capabilities(
2629+
self._client_capabilities, claims_challenge)),
2630+
**kwargs))
2631+
if "access_token" in response:
2632+
response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
2633+
telemetry_context.update_telemetry(response)
2634+
return response

msal/oauth2cli/oauth2.py

Lines changed: 68 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,11 @@ 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 argument.
110+
When it accepts one argument, it will receive a dict containing
111+
``"client_id"``, ``"token_endpoint"``, and optionally ``"fmi_path"``
112+
(when an FMI path is set on the current request).
107113
client_assertion_type (str):
108114
The type of your :attr:`client_assertion` parameter.
109115
It is typically the value of :attr:`CLIENT_ASSERTION_TYPE_SAML2` or
@@ -168,6 +174,35 @@ def __init__(
168174
# A workaround for requests not supporting session-wide timeout
169175
self._http_client.request, timeout=timeout)
170176

177+
@staticmethod
178+
def _accepts_context(func):
179+
"""Check if a callable accepts at least one positional argument."""
180+
try:
181+
sig = inspect.signature(func)
182+
params = [
183+
p for p in sig.parameters.values()
184+
if p.kind in (
185+
inspect.Parameter.POSITIONAL_ONLY,
186+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
187+
)
188+
]
189+
return len(params) >= 1
190+
except (ValueError, TypeError):
191+
return False # Signature not inspectable; treat as zero-arg
192+
193+
def _invoke_assertion_callable(self, assertion_callable, data=None):
194+
"""Invoke an assertion callable, passing context if it accepts one."""
195+
if self._accepts_context(assertion_callable):
196+
context = {
197+
"client_id": self.client_id,
198+
"token_endpoint": self.configuration.get(
199+
"token_endpoint", ""),
200+
}
201+
if data and data.get("fmi_path"):
202+
context["fmi_path"] = data["fmi_path"]
203+
return assertion_callable(context)
204+
return assertion_callable()
205+
171206
def _build_auth_request_params(self, response_type, **kwargs):
172207
# response_type is a string defined in
173208
# https://tools.ietf.org/html/rfc6749#section-3.1.1
@@ -198,11 +233,11 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749
198233
# See https://tools.ietf.org/html/rfc7521#section-4.2
199234
encoder = self.client_assertion_encoders.get(
200235
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
236+
if callable(self.client_assertion):
237+
raw = self._invoke_assertion_callable(self.client_assertion, data)
238+
else:
239+
raw = self.client_assertion
240+
_data["client_assertion"] = encoder(raw)
206241

207242
_data.update(self.default_body) # It may contain authen parameters
208243
_data.update(data or {}) # So the content in data param prevails
@@ -770,6 +805,34 @@ class initialization.
770805
data.update(scope=scope)
771806
return self._obtain_token("client_credentials", data=data, **kwargs)
772807

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

msal/token_cache.py

Lines changed: 5 additions & 0 deletions
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

0 commit comments

Comments
 (0)