Skip to content

Commit d61a0b1

Browse files
committed
CCA federated by managed identity
1 parent f2a4aa5 commit d61a0b1

File tree

6 files changed

+121
-31
lines changed

6 files changed

+121
-31
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
)
4141

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: 91 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313

1414
from .oauth2cli import Client, JwtAssertionCreator
15+
from .oauth2cli.assertion import AutoRefresher
1516
from .oauth2cli.oidc import decode_part
1617
from .authority import Authority, WORLD_WIDE
1718
from .mex import send_request as mex_send_request
@@ -22,6 +23,7 @@
2223
from .region import _detect_region
2324
from .throttled_http_client import ThrottledHttpClient
2425
from .cloudshell import _is_running_in_cloud_shell
26+
from .managed_identity import ManagedIdentity, ManagedIdentityClient
2527

2628

2729
# The __init__.py will import this. Not the other way around.
@@ -230,32 +232,80 @@ def __init__(
230232
The thumbprint is available in your app's registration in Azure Portal.
231233
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>`_.
232234
233-
*Added in version 0.5.0*:
234-
public_certificate (optional) is public key certificate
235-
which will be sent through 'x5c' JWT header only for
236-
subject name and issuer authentication to support cert auto rolls.
237-
238-
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
239-
"the certificate containing
240-
the public key corresponding to the key used to digitally sign the
241-
JWS MUST be the first certificate. This MAY be followed by
242-
additional certificates, with each subsequent certificate being the
243-
one used to certify the previous one."
244-
However, your certificate's issuer may use a different order.
245-
So, if your attempt ends up with an error AADSTS700027 -
246-
"The provided signature value did not match the expected signature value",
247-
you may try use only the leaf cert (in PEM/str format) instead.
248-
249-
*Added in version 1.13.0*:
250-
It can also be a completely pre-signed assertion that you've assembled yourself.
251-
Simply pass a container containing only the key "client_assertion", like this::
235+
.. admonition:: Using ``public_certificate`` to support Subject Name/Issuer Auth
252236
253-
{
254-
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
255-
}
237+
*Added in version 0.5.0*:
238+
public_certificate (optional) is public key certificate
239+
which will be sent through 'x5c' JWT header only for
240+
subject name and issuer authentication to support cert auto rolls.
241+
242+
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
243+
"the certificate containing
244+
the public key corresponding to the key used to digitally sign the
245+
JWS MUST be the first certificate. This MAY be followed by
246+
additional certificates, with each subsequent certificate being the
247+
one used to certify the previous one."
248+
However, your certificate's issuer may use a different order.
249+
So, if your attempt ends up with an error AADSTS700027 -
250+
"The provided signature value did not match the expected signature value",
251+
you may try use only the leaf cert (in PEM/str format) instead.
252+
253+
.. admonition:: Supporting raw assertion obtained from elsewhere
254+
255+
*Added in version 1.13.0*:
256+
It can also be a completely pre-signed assertion that you've assembled yourself.
257+
Simply pass a container containing only the key "client_assertion", like this::
258+
259+
{
260+
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
261+
}
262+
263+
.. admonition:: Supporting workload identity federated by Managed Identity
264+
265+
*Added in version 1.29.0*:
266+
A confidential client app can authenticate via a managed identity.
267+
This is known as "federated identity credential (FIC)" or
268+
`"Workload identity federation" <https://learn.microsoft.com/entra/workload-id/workload-identity-federation>`_.
269+
270+
Once you setup the federation, the following declarative API
271+
takes care of the managed identity token acquisition for you.
272+
You just need to assign ``client_credential``
273+
with an instance of :py:class:`msal.SystemAssignedManagedIdentity`
274+
or :py:class:`msal.UserAssignedManagedIdentity`, for example::
275+
276+
app = msal.ConfidentialClientApplication(
277+
"my_client_id",
278+
client_credential=msal.SystemAssignedManagedIdentity(),
279+
...)
280+
281+
or their equivalent ``dict`` representation, such as::
282+
283+
app = msal.ConfidentialClientApplication(
284+
"my_client_id",
285+
client_credential={
286+
"ManagedIdentityIdType": "SystemAssigned",
287+
"Id": None},
288+
...)
289+
290+
The second example above also imples that you can
291+
load the ``dict`` from its equivalent ``json`` representation,
292+
which could in turn be read from an ENV VAR, for instance::
293+
294+
## Supposed this is one of your ENV VAR
295+
# CRED={"ManagedIdentityIdType": "SystemAssigned", "Id": null}
296+
## Now you can read it like this
297+
app = msal.ConfidentialClientApplication(
298+
"my_client_id",
299+
client_credential=json.loads(os.getenv("CRED")),
300+
...)
301+
302+
This way, your same Confidential Client Application implementation
303+
can be configured to use either client secret or managed identity,
304+
without code change. Write once, run anywhere with managed identity.
256305
257306
:type client_credential: Union[dict, str]
258307
308+
259309
:param dict client_claims:
260310
*Added in version 0.5.0*:
261311
It is a dictionary of extra claims that would be signed by
@@ -650,13 +700,27 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
650700
if self.app_version:
651701
default_headers['x-app-ver'] = self.app_version
652702
default_body = {"client_info": 1}
653-
if isinstance(client_credential, dict):
703+
if ManagedIdentity.is_managed_identity(client_credential):
704+
# Federated Identity Credential (FIC), a.k.a. workload identity
705+
# https://learn.microsoft.com/entra/workload-id/workload-identity-federation
706+
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT
707+
client_assertion = AutoRefresher(
708+
lambda: ManagedIdentityClient(
709+
client_credential, self.http_client,
710+
).acquire_token_for_client(
711+
resource="api://AzureADTokenExchange",
712+
).get("access_token"),
713+
expires_in=3600, # Managed Identity token expires in 1 hour
714+
)
715+
logger.debug("CCA federated by Managed Identity specified via client_credential")
716+
elif isinstance(client_credential, dict):
654717
assert (("private_key" in client_credential
655718
and "thumbprint" in client_credential) or
656719
"client_assertion" in client_credential)
657720
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT
658721
if 'client_assertion' in client_credential:
659722
client_assertion = client_credential['client_assertion']
723+
logger.debug("CCA authenticated by assertion specified in client_credential")
660724
else:
661725
headers = {}
662726
if 'public_certificate' in client_credential:
@@ -671,14 +735,16 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
671735
_str2bytes(client_credential["passphrase"]),
672736
backend=default_backend(), # It was a required param until 2020
673737
)
674-
assertion = JwtAssertionCreator(
738+
assertion_creator = JwtAssertionCreator(
675739
unencrypted_private_key, algorithm="RS256",
676740
sha1_thumbprint=client_credential.get("thumbprint"), headers=headers)
677-
client_assertion = assertion.create_regenerative_assertion(
741+
client_assertion = assertion_creator.create_regenerative_assertion(
678742
audience=authority.token_endpoint, issuer=self.client_id,
679743
additional_claims=self.client_claims or {})
744+
logger.debug("CCA authenticated by certificate: {...}")
680745
else:
681746
default_body['client_secret'] = client_credential
747+
logger.debug("CCA authenticated by secret: ******")
682748
central_configuration = {
683749
"authorization_endpoint": authority.authorization_endpoint,
684750
"token_endpoint": authority.token_endpoint,

msal/managed_identity.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ def __init__(
137137
self, managed_identity, *, http_client, token_cache=None, http_cache=None):
138138
"""Create a managed identity client.
139139
140+
.. note::
141+
You do not have to work with Managed Identity and this class directly.
142+
143+
A better approach is to use
144+
`Workload identity federation <https://learn.microsoft.com/entra/workload-id/workload-identity-federation>`_.
145+
Specifically, you can use MSAL's
146+
:class:`msal.ConfidentialClientApplication` and feed
147+
its :paramref:`msal.ClientApplication.client_credential` parameter
148+
with an instance of :class:`msal.SystemAssignedManagedIdentity`
149+
or :class:`msal.UserAssignedManagedIdentity`.
150+
140151
:param dict managed_identity:
141152
It accepts an instance of :class:`SystemAssignedManagedIdentity`
142153
or :class:`UserAssignedManagedIdentity`.

sample/.env.sample.entra-id

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@ AUTHORITY=<authority url>
1111
# The following variables are required for the app to run.
1212
CLIENT_ID=<client id>
1313

14-
# Leave it empty if you are using a public client which has no client secret.
14+
15+
# Leave it empty if your app does not have a client secret
1516
CLIENT_SECRET=<client secret>
17+
# Leave it intact if you do not intend to use client credential.
18+
# One of the usage here is to configure your app federated by a managed identity
19+
# e.g. {"ManagedIdentityIdType": "SystemAssigned", "Id": null}
20+
# or {"ManagedIdentityIdType": "ClientId", "Id": "foo"}
21+
# or {"ManagedIdentityIdType": "ResourceId", "Id": "foo"}
22+
# or {"ManagedIdentityIdType": "ObjectId", "Id": "foo"}
23+
CLIENT_CREDENTIAL_JSON=null
1624

1725
# Multiple scopes can be added into the same line, separated by a space.
1826
# Here we use a Microsoft Graph API as an example

sample/confidential_client_secret_sample.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
os.getenv('CLIENT_ID'),
4444
authority=os.getenv('AUTHORITY'), # For Entra ID or External ID
4545
oidc_authority=os.getenv('OIDC_AUTHORITY'), # For External ID with custom domain
46-
client_credential=os.getenv('CLIENT_SECRET'),
46+
client_credential=os.getenv('CLIENT_SECRET') or json.loads(
47+
os.getenv("CLIENT_CREDENTIAL_JSON")),
4748
token_cache=global_token_cache, # Let this app (re)use an existing token cache.
4849
# If absent, ClientApplication will create its own empty token cache
4950
)

0 commit comments

Comments
 (0)