Skip to content

Commit 396a0ed

Browse files
committed
CCA federated by managed identity
1 parent c1fedad commit 396a0ed

File tree

5 files changed

+118
-28
lines changed

5 files changed

+118
-28
lines changed

msal/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from .token_cache import TokenCache, SerializableTokenCache
3636
from .auth_scheme import PopAuthScheme
3737
from .managed_identity import (
38-
SystemAssignedManagedIdentity, UserAssignedManagedIdentity,
38+
ManagedIdentity, SystemAssignedManagedIdentity, UserAssignedManagedIdentity,
3939
ManagedIdentityClient,
4040
ManagedIdentityError,
4141
ArcPlatformNotSupportedError,

msal/__main__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
shiv -e msal.__main__._main -o msaltest-on-os-name.pyz .
1212
"""
1313
import base64, getpass, json, logging, sys, os, atexit, msal
14+
#import http.client as http_client
15+
#http_client.HTTPConnection.debuglevel = 1 # Show http request/response on the wire
1416

1517
_token_cache_filename = "msal_cache.bin"
1618
global_cache = msal.SerializableTokenCache()
@@ -263,9 +265,11 @@ def _main():
263265
{"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"},
264266
{
265267
"client_id": os.getenv("CLIENT_ID"),
266-
"client_secret": os.getenv("CLIENT_SECRET"),
267-
"name": "A confidential client app (CCA) whose settings are defined "
268-
"in environment variables CLIENT_ID and CLIENT_SECRET",
268+
"client_secret": os.getenv("CLIENT_SECRET", msal.SystemAssignedManagedIdentity()),
269+
"name": "A confidential client app (CCA) whose "
270+
"(1) client_id is defined in environment variables CLIENT_ID "
271+
"(2) client_secret is either in env var CLIENT_SECRET (if any) "
272+
"or federated by system-assigned managed identity (if applicable)",
269273
},
270274
],
271275
option_renderer=lambda a: a["name"],

msal/application.py

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99

1010
from .oauth2cli import Client, JwtAssertionCreator
11+
from .oauth2cli.assertion import AutoRefresher
1112
from .oauth2cli.oidc import decode_part
1213
from .authority import Authority, WORLD_WIDE
1314
from .mex import send_request as mex_send_request
@@ -18,6 +19,7 @@
1819
from .region import _detect_region
1920
from .throttled_http_client import ThrottledHttpClient
2021
from .cloudshell import _is_running_in_cloud_shell
22+
from .managed_identity import ManagedIdentity, ManagedIdentityClient
2123

2224

2325
# The __init__.py will import this. Not the other way around.
@@ -249,29 +251,76 @@ def __init__(
249251
The thumbprint is available in your app's registration in Azure Portal.
250252
Alternatively, you can `calculate the thumbprint <https://github.com/Azure/azure-sdk-for-python/blob/07d10639d7e47f4852eaeb74aef5d569db499d6e/sdk/identity/azure-identity/azure/identity/_credentials/certificate.py#L94-L97>`_.
251253
252-
*Added in version 0.5.0*:
253-
public_certificate (optional) is public key certificate
254-
which will be sent through 'x5c' JWT header only for
255-
subject name and issuer authentication to support cert auto rolls.
256-
257-
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
258-
"the certificate containing
259-
the public key corresponding to the key used to digitally sign the
260-
JWS MUST be the first certificate. This MAY be followed by
261-
additional certificates, with each subsequent certificate being the
262-
one used to certify the previous one."
263-
However, your certificate's issuer may use a different order.
264-
So, if your attempt ends up with an error AADSTS700027 -
265-
"The provided signature value did not match the expected signature value",
266-
you may try use only the leaf cert (in PEM/str format) instead.
267-
268-
*Added in version 1.13.0*:
269-
It can also be a completely pre-signed assertion that you've assembled yourself.
270-
Simply pass a container containing only the key "client_assertion", like this::
254+
.. admonition:: Using ``public_certificate`` to support Subject Name/Issuer Auth
271255
272-
{
273-
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
274-
}
256+
*Added in version 0.5.0*:
257+
public_certificate (optional) is public key certificate
258+
which will be sent through 'x5c' JWT header only for
259+
subject name and issuer authentication to support cert auto rolls.
260+
261+
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
262+
"the certificate containing
263+
the public key corresponding to the key used to digitally sign the
264+
JWS MUST be the first certificate. This MAY be followed by
265+
additional certificates, with each subsequent certificate being the
266+
one used to certify the previous one."
267+
However, your certificate's issuer may use a different order.
268+
So, if your attempt ends up with an error AADSTS700027 -
269+
"The provided signature value did not match the expected signature value",
270+
you may try use only the leaf cert (in PEM/str format) instead.
271+
272+
.. admonition:: Supporting raw assertion obtained from elsewhere
273+
274+
*Added in version 1.13.0*:
275+
It can also be a completely pre-signed assertion that you've assembled yourself.
276+
Simply pass a container containing only the key "client_assertion", like this::
277+
278+
{
279+
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
280+
}
281+
282+
.. admonition:: Supporting workload identity federated by Managed Identity
283+
284+
*Added in version 1.29.0*:
285+
A confidential client app can authenticate via a managed identity.
286+
This is known as "federated identity credential (FIC)" or
287+
`"Workload identity federation" <https://learn.microsoft.com/entra/workload-id/workload-identity-federation>`_.
288+
289+
Once you setup the federation, the following declarative API
290+
takes care of the managed identity token acquisition for you.
291+
You just need to assign ``client_credential``
292+
with an instance of :py:class:`msal.SystemAssignedManagedIdentity`
293+
or :py:class:`msal.UserAssignedManagedIdentity`, for example::
294+
295+
app = msal.ConfidentialClientApplication(
296+
"my_client_id",
297+
client_credential=msal.SystemAssignedManagedIdentity(),
298+
...)
299+
300+
or their equivalent ``dict`` representation, such as::
301+
302+
app = msal.ConfidentialClientApplication(
303+
"my_client_id",
304+
client_credential={
305+
"ManagedIdentityIdType": "SystemAssigned",
306+
"Id": None},
307+
...)
308+
309+
The second example above also imples that you can
310+
load the ``dict`` from its equivalent ``json`` representation,
311+
which could in turn be read from an ENV VAR, for instance::
312+
313+
## Supposed this is one of your ENV VAR
314+
# CRED={"ManagedIdentityIdType": "SystemAssigned", "Id": null}
315+
## Now you can read it like this
316+
app = msal.ConfidentialClientApplication(
317+
"my_client_id",
318+
client_credential=json.loads(os.getenv("CRED")),
319+
...)
320+
321+
This way, your same Confidential Client Application implementation
322+
can be configured to use either client secret or managed identity,
323+
without code change. Write once, run anywhere with managed identity.
275324
276325
.. admonition:: Supporting reading client cerficates from PFX files
277326
@@ -289,6 +338,7 @@ def __init__(
289338
290339
:type client_credential: Union[dict, str]
291340
341+
292342
:param dict client_claims:
293343
*Added in version 0.5.0*:
294344
It is a dictionary of extra claims that would be signed by
@@ -691,12 +741,29 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
691741
if self.app_version:
692742
default_headers['x-app-ver'] = self.app_version
693743
default_body = {"client_info": 1}
694-
if isinstance(client_credential, dict):
744+
if ManagedIdentity.is_managed_identity(client_credential):
745+
# Federated Identity Credential (FIC), a.k.a. workload identity
746+
# https://learn.microsoft.com/entra/workload-id/workload-identity-federation
747+
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT
748+
client_assertion = AutoRefresher(
749+
lambda: ManagedIdentityClient(
750+
client_credential, self.http_client,
751+
).acquire_token_for_client(
752+
resource="api://AzureADTokenExchange",
753+
).get("access_token"),
754+
expires_in=3600, # Managed Identity token expires in 1 hour
755+
)
756+
logger.debug("CCA federated by Managed Identity specified via client_credential")
757+
elif isinstance(client_credential, dict):
758+
assert (("private_key" in client_credential
759+
and "thumbprint" in client_credential) or
760+
"client_assertion" in client_credential)
695761
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT
696762
# Use client_credential.get("...") rather than "..." in client_credential
697763
# so that we can ignore an empty string came from an empty ENV VAR.
698764
if client_credential.get("client_assertion"):
699765
client_assertion = client_credential['client_assertion']
766+
logger.debug("CCA authenticated by assertion specified in client_credential")
700767
else:
701768
headers = {}
702769
if client_credential.get('public_certificate'):
@@ -726,8 +793,10 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
726793
client_assertion = assertion.create_regenerative_assertion(
727794
audience=authority.token_endpoint, issuer=self.client_id,
728795
additional_claims=self.client_claims or {})
796+
logger.debug("CCA authenticated by certificate: {...}")
729797
else:
730798
default_body['client_secret'] = client_credential
799+
logger.debug("CCA authenticated by secret: ******")
731800
central_configuration = {
732801
"authorization_endpoint": authority.authorization_endpoint,
733802
"token_endpoint": authority.token_endpoint,

msal/managed_identity.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ def __init__(
161161
):
162162
"""Create a managed identity client.
163163
164+
.. note::
165+
You do not have to work with Managed Identity and this class directly.
166+
167+
A better approach is to use
168+
`Workload identity federation <https://learn.microsoft.com/entra/workload-id/workload-identity-federation>`_.
169+
Specifically, you can use MSAL's
170+
:class:`msal.ConfidentialClientApplication` and feed
171+
its :paramref:`msal.ClientApplication.client_credential` parameter
172+
with an instance of :class:`msal.SystemAssignedManagedIdentity`
173+
or :class:`msal.UserAssignedManagedIdentity`.
174+
164175
:param managed_identity:
165176
It accepts an instance of :class:`SystemAssignedManagedIdentity`
166177
or :class:`UserAssignedManagedIdentity`.

sample/.env.sample.entra-id

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ CLIENT_ID=<client id>
1515
#CLIENT_SECRET=<client secret>
1616

1717
# Configure this if you are using a confidential client which has a client credential.
18-
# Example value: {"private_key_pfx_path": "/path/to/your.pfx"}
18+
# Example value for using a PFX file: {"private_key_pfx_path": "/path/to/your.pfx"}
19+
#
20+
# More examples here for configuring your app federated by a managed identity
21+
# e.g. {"ManagedIdentityIdType": "SystemAssigned", "Id": null}
22+
# or {"ManagedIdentityIdType": "ClientId", "Id": "foo"}
23+
# or {"ManagedIdentityIdType": "ResourceId", "Id": "foo"}
24+
# or {"ManagedIdentityIdType": "ObjectId", "Id": "foo"}
1925
CLIENT_CREDENTIAL_JSON=<client credential json>
2026

2127
# Multiple scopes can be added into the same line, separated by a space.

0 commit comments

Comments
 (0)