Skip to content

Commit 579888d

Browse files
tracyboehrerTracy Boehrer
and
Tracy Boehrer
authored
UserAssigned MSI support (#2129)
* Added ManagedIdentity * Missing ConfigurationServiceClientCredentialFactory awaits * ManagedIdentityAppCredentials needs ManagedIdentity dict * Added missing PermissionError descriptions * Black reformatting in botbuilder-core --------- Co-authored-by: Tracy Boehrer <trboehre@microsoft.com>
1 parent d7cd937 commit 579888d

File tree

10 files changed

+170
-19
lines changed

10 files changed

+170
-19
lines changed

libraries/botbuilder-core/botbuilder/core/channel_service_handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,9 @@ async def _authenticate(self, auth_header: str) -> ClaimsIdentity:
504504
)
505505
if not is_auth_disabled:
506506
# No auth header. Auth is required. Request is not authorized.
507-
raise PermissionError()
507+
raise PermissionError(
508+
"Authorization is required but has been disabled."
509+
)
508510

509511
# In the scenario where Auth is disabled, we still want to have the
510512
# IsAuthenticated flag set in the ClaimsIdentity. To do this requires

libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/cloud_adapter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ async def process(
107107
return Response(status=201)
108108
else:
109109
raise HTTPMethodNotAllowed
110-
except (HTTPUnauthorized, PermissionError) as _:
110+
except PermissionError:
111111
raise HTTPUnauthorized
112112

113113
async def _connect(

libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_service_client_credential_factory.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,23 @@
44
from logging import Logger
55
from typing import Any
66

7+
from msrest.authentication import Authentication
8+
79
from botframework.connector.auth import PasswordServiceClientCredentialFactory
10+
from botframework.connector.auth import ManagedIdentityServiceClientCredentialsFactory
11+
from botframework.connector.auth import ServiceClientCredentialsFactory
812

913

10-
class ConfigurationServiceClientCredentialFactory(
11-
PasswordServiceClientCredentialFactory
12-
):
14+
class ConfigurationServiceClientCredentialFactory(ServiceClientCredentialsFactory):
1315
def __init__(self, configuration: Any, *, logger: Logger = None) -> None:
16+
self._inner = None
17+
1418
app_type = (
1519
configuration.APP_TYPE
1620
if hasattr(configuration, "APP_TYPE")
1721
else "MultiTenant"
18-
)
22+
).lower()
23+
1924
app_id = configuration.APP_ID if hasattr(configuration, "APP_ID") else None
2025
app_password = (
2126
configuration.APP_PASSWORD
@@ -24,10 +29,25 @@ def __init__(self, configuration: Any, *, logger: Logger = None) -> None:
2429
)
2530
app_tenantid = None
2631

27-
if app_type == "UserAssignedMsi":
28-
raise Exception("UserAssignedMsi APP_TYPE is not supported")
32+
if app_type == "userassignedmsi":
33+
if not app_id:
34+
raise Exception("Property 'APP_ID' is expected in configuration object")
35+
36+
app_tenantid = (
37+
configuration.APP_TENANTID
38+
if hasattr(configuration, "APP_TENANTID")
39+
else None
40+
)
41+
if not app_tenantid:
42+
raise Exception(
43+
"Property 'APP_TENANTID' is expected in configuration object"
44+
)
45+
46+
self._inner = ManagedIdentityServiceClientCredentialsFactory(
47+
app_id, logger=logger
48+
)
2949

30-
if app_type == "SingleTenant":
50+
elif app_type == "singletenant":
3151
app_tenantid = (
3252
configuration.APP_TENANTID
3353
if hasattr(configuration, "APP_TENANTID")
@@ -45,4 +65,36 @@ def __init__(self, configuration: Any, *, logger: Logger = None) -> None:
4565
"Property 'APP_TENANTID' is expected in configuration object"
4666
)
4767

48-
super().__init__(app_id, app_password, app_tenantid, logger=logger)
68+
self._inner = PasswordServiceClientCredentialFactory(
69+
app_id, app_password, app_tenantid, logger=logger
70+
)
71+
72+
# Default to MultiTenant
73+
else:
74+
if not app_id:
75+
raise Exception("Property 'APP_ID' is expected in configuration object")
76+
if not app_password:
77+
raise Exception(
78+
"Property 'APP_PASSWORD' is expected in configuration object"
79+
)
80+
81+
self._inner = PasswordServiceClientCredentialFactory(
82+
app_id, app_password, None, logger=logger
83+
)
84+
85+
async def is_valid_app_id(self, app_id: str) -> bool:
86+
return await self._inner.is_valid_app_id(app_id)
87+
88+
async def is_authentication_disabled(self) -> bool:
89+
return await self._inner.is_authentication_disabled()
90+
91+
async def create_credentials(
92+
self,
93+
app_id: str,
94+
oauth_scope: str,
95+
login_endpoint: str,
96+
validate_authority: bool,
97+
) -> Authentication:
98+
return await self._inner.create_credentials(
99+
app_id, oauth_scope, login_endpoint, validate_authority
100+
)

libraries/botframework-connector/botframework/connector/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@
2727
from .service_client_credentials_factory import *
2828
from .user_token_client import *
2929
from .authentication_configuration import *
30+
from .managedidentity_app_credentials import *
31+
from .managedidentity_service_client_credential_factory import *

libraries/botframework-connector/botframework/connector/auth/_parameterized_bot_framework_authentication.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,11 +473,11 @@ async def _government_channel_validation_validate_identity(
473473
):
474474
if identity is None:
475475
# No valid identity. Not Authorized.
476-
raise PermissionError()
476+
raise PermissionError("Identity missing")
477477

478478
if not identity.is_authenticated:
479479
# The token is in some way invalid. Not Authorized.
480-
raise PermissionError()
480+
raise PermissionError("Invalid token")
481481

482482
# Now check that the AppID in the claim set matches
483483
# what we're looking for. Note that in a multi-tenant bot, this value
@@ -487,12 +487,12 @@ async def _government_channel_validation_validate_identity(
487487
# Look for the "aud" claim, but only if issued from the Bot Framework
488488
issuer = identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM)
489489
if issuer != self._to_bot_from_channel_token_issuer:
490-
raise PermissionError()
490+
raise PermissionError("'iss' claim missing")
491491

492492
app_id = identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM)
493493
if not app_id:
494494
# The relevant audience Claim MUST be present. Not Authorized.
495-
raise PermissionError()
495+
raise PermissionError("'aud' claim missing")
496496

497497
# The AppId from the claim in the token must match the AppId specified by the developer.
498498
# In this case, the token is destined for the app, so we find the app ID in the audience claim.
@@ -507,8 +507,8 @@ async def _government_channel_validation_validate_identity(
507507
)
508508
if not service_url_claim:
509509
# Claim must be present. Not Authorized.
510-
raise PermissionError()
510+
raise PermissionError("'serviceurl' claim missing")
511511

512512
if service_url_claim != service_url:
513513
# Claim must match. Not Authorized.
514-
raise PermissionError()
514+
raise PermissionError("Invalid 'serviceurl' claim")

libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def authenticate_request(
4646
auth_is_disabled = await credentials.is_authentication_disabled()
4747
if not auth_is_disabled:
4848
# No Auth Header. Auth is required. Request is not authorized.
49-
raise PermissionError("Unauthorized Access. Request is not authorized")
49+
raise PermissionError("Required Authorization token was not supplied")
5050

5151
# Check if the activity is for a skill call and is coming from the Emulator.
5252
try:
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from abc import ABC
5+
6+
import msal
7+
import requests
8+
9+
from .app_credentials import AppCredentials
10+
from .microsoft_app_credentials import MicrosoftAppCredentials
11+
12+
13+
class ManagedIdentityAppCredentials(AppCredentials, ABC):
14+
"""
15+
AppCredentials implementation using application ID and password.
16+
"""
17+
18+
global_token_cache = msal.TokenCache()
19+
20+
def __init__(self, app_id: str, oauth_scope: str = None):
21+
# super will set proper scope and endpoint.
22+
super().__init__(
23+
app_id=app_id,
24+
oauth_scope=oauth_scope,
25+
)
26+
27+
self._managed_identity = {"ManagedIdentityIdType": "ClientId", "Id": app_id}
28+
29+
self.app = None
30+
31+
@staticmethod
32+
def empty():
33+
return MicrosoftAppCredentials("", "")
34+
35+
def get_access_token(self, force_refresh: bool = False) -> str:
36+
"""
37+
Implementation of AppCredentials.get_token.
38+
:return: The access token for the given app id and password.
39+
"""
40+
41+
# Firstly, looks up a token from cache
42+
# Since we are looking for token for the current app, NOT for an end user,
43+
# notice we give account parameter as None.
44+
auth_token = self.__get_msal_app().acquire_token_for_client(
45+
resource=self.oauth_scope
46+
)
47+
return auth_token["access_token"]
48+
49+
def __get_msal_app(self):
50+
if not self.app:
51+
self.app = msal.ManagedIdentityClient(
52+
self._managed_identity,
53+
http_client=requests.Session(),
54+
token_cache=ManagedIdentityAppCredentials.global_token_cache,
55+
)
56+
return self.app
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from logging import Logger
5+
6+
from msrest.authentication import Authentication
7+
8+
from .managedidentity_app_credentials import ManagedIdentityAppCredentials
9+
from .microsoft_app_credentials import MicrosoftAppCredentials
10+
from .service_client_credentials_factory import ServiceClientCredentialsFactory
11+
12+
13+
class ManagedIdentityServiceClientCredentialsFactory(ServiceClientCredentialsFactory):
14+
def __init__(self, app_id: str = None, *, logger: Logger = None) -> None:
15+
self.app_id = app_id
16+
self._logger = logger
17+
18+
async def is_valid_app_id(self, app_id: str) -> bool:
19+
return app_id == self.app_id
20+
21+
async def is_authentication_disabled(self) -> bool:
22+
return not self.app_id
23+
24+
async def create_credentials(
25+
self,
26+
app_id: str,
27+
oauth_scope: str,
28+
login_endpoint: str,
29+
validate_authority: bool,
30+
) -> Authentication:
31+
if await self.is_authentication_disabled():
32+
return MicrosoftAppCredentials.empty()
33+
34+
if not await self.is_valid_app_id(app_id):
35+
raise Exception("Invalid app_id")
36+
37+
credentials = ManagedIdentityAppCredentials(app_id, oauth_scope)
38+
39+
return credentials

libraries/botframework-connector/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ botbuilder-schema==4.16.0
33
requests==2.32.0
44
PyJWT==2.4.0
55
cryptography==42.0.4
6-
msal==1.*
6+
msal>=1.29.0

libraries/botframework-connector/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# "requests>=2.23.0,<2.26",
1212
"PyJWT>=2.4.0",
1313
"botbuilder-schema==4.16.0",
14-
"msal==1.*",
14+
"msal>=1.29.0",
1515
]
1616

1717
root = os.path.abspath(os.path.dirname(__file__))

0 commit comments

Comments
 (0)