Skip to content

Commit

Permalink
UserAssigned MSI support (#2129)
Browse files Browse the repository at this point in the history
* Added ManagedIdentity

* Missing ConfigurationServiceClientCredentialFactory awaits

* ManagedIdentityAppCredentials needs ManagedIdentity dict

* Added missing PermissionError descriptions

* Black reformatting in botbuilder-core

---------

Co-authored-by: Tracy Boehrer <[email protected]>
  • Loading branch information
tracyboehrer and Tracy Boehrer authored Jul 1, 2024
1 parent d7cd937 commit 579888d
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,9 @@ async def _authenticate(self, auth_header: str) -> ClaimsIdentity:
)
if not is_auth_disabled:
# No auth header. Auth is required. Request is not authorized.
raise PermissionError()
raise PermissionError(
"Authorization is required but has been disabled."
)

# In the scenario where Auth is disabled, we still want to have the
# IsAuthenticated flag set in the ClaimsIdentity. To do this requires
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ async def process(
return Response(status=201)
else:
raise HTTPMethodNotAllowed
except (HTTPUnauthorized, PermissionError) as _:
except PermissionError:
raise HTTPUnauthorized

async def _connect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@
from logging import Logger
from typing import Any

from msrest.authentication import Authentication

from botframework.connector.auth import PasswordServiceClientCredentialFactory
from botframework.connector.auth import ManagedIdentityServiceClientCredentialsFactory
from botframework.connector.auth import ServiceClientCredentialsFactory


class ConfigurationServiceClientCredentialFactory(
PasswordServiceClientCredentialFactory
):
class ConfigurationServiceClientCredentialFactory(ServiceClientCredentialsFactory):
def __init__(self, configuration: Any, *, logger: Logger = None) -> None:
self._inner = None

app_type = (
configuration.APP_TYPE
if hasattr(configuration, "APP_TYPE")
else "MultiTenant"
)
).lower()

app_id = configuration.APP_ID if hasattr(configuration, "APP_ID") else None
app_password = (
configuration.APP_PASSWORD
Expand All @@ -24,10 +29,25 @@ def __init__(self, configuration: Any, *, logger: Logger = None) -> None:
)
app_tenantid = None

if app_type == "UserAssignedMsi":
raise Exception("UserAssignedMsi APP_TYPE is not supported")
if app_type == "userassignedmsi":
if not app_id:
raise Exception("Property 'APP_ID' is expected in configuration object")

app_tenantid = (
configuration.APP_TENANTID
if hasattr(configuration, "APP_TENANTID")
else None
)
if not app_tenantid:
raise Exception(
"Property 'APP_TENANTID' is expected in configuration object"
)

self._inner = ManagedIdentityServiceClientCredentialsFactory(
app_id, logger=logger
)

if app_type == "SingleTenant":
elif app_type == "singletenant":
app_tenantid = (
configuration.APP_TENANTID
if hasattr(configuration, "APP_TENANTID")
Expand All @@ -45,4 +65,36 @@ def __init__(self, configuration: Any, *, logger: Logger = None) -> None:
"Property 'APP_TENANTID' is expected in configuration object"
)

super().__init__(app_id, app_password, app_tenantid, logger=logger)
self._inner = PasswordServiceClientCredentialFactory(
app_id, app_password, app_tenantid, logger=logger
)

# Default to MultiTenant
else:
if not app_id:
raise Exception("Property 'APP_ID' is expected in configuration object")
if not app_password:
raise Exception(
"Property 'APP_PASSWORD' is expected in configuration object"
)

self._inner = PasswordServiceClientCredentialFactory(
app_id, app_password, None, logger=logger
)

async def is_valid_app_id(self, app_id: str) -> bool:
return await self._inner.is_valid_app_id(app_id)

async def is_authentication_disabled(self) -> bool:
return await self._inner.is_authentication_disabled()

async def create_credentials(
self,
app_id: str,
oauth_scope: str,
login_endpoint: str,
validate_authority: bool,
) -> Authentication:
return await self._inner.create_credentials(
app_id, oauth_scope, login_endpoint, validate_authority
)
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@
from .service_client_credentials_factory import *
from .user_token_client import *
from .authentication_configuration import *
from .managedidentity_app_credentials import *
from .managedidentity_service_client_credential_factory import *
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,11 @@ async def _government_channel_validation_validate_identity(
):
if identity is None:
# No valid identity. Not Authorized.
raise PermissionError()
raise PermissionError("Identity missing")

if not identity.is_authenticated:
# The token is in some way invalid. Not Authorized.
raise PermissionError()
raise PermissionError("Invalid token")

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

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

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

if service_url_claim != service_url:
# Claim must match. Not Authorized.
raise PermissionError()
raise PermissionError("Invalid 'serviceurl' claim")
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def authenticate_request(
auth_is_disabled = await credentials.is_authentication_disabled()
if not auth_is_disabled:
# No Auth Header. Auth is required. Request is not authorized.
raise PermissionError("Unauthorized Access. Request is not authorized")
raise PermissionError("Required Authorization token was not supplied")

# Check if the activity is for a skill call and is coming from the Emulator.
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from abc import ABC

import msal
import requests

from .app_credentials import AppCredentials
from .microsoft_app_credentials import MicrosoftAppCredentials


class ManagedIdentityAppCredentials(AppCredentials, ABC):
"""
AppCredentials implementation using application ID and password.
"""

global_token_cache = msal.TokenCache()

def __init__(self, app_id: str, oauth_scope: str = None):
# super will set proper scope and endpoint.
super().__init__(
app_id=app_id,
oauth_scope=oauth_scope,
)

self._managed_identity = {"ManagedIdentityIdType": "ClientId", "Id": app_id}

self.app = None

@staticmethod
def empty():
return MicrosoftAppCredentials("", "")

def get_access_token(self, force_refresh: bool = False) -> str:
"""
Implementation of AppCredentials.get_token.
:return: The access token for the given app id and password.
"""

# Firstly, looks up a token from cache
# Since we are looking for token for the current app, NOT for an end user,
# notice we give account parameter as None.
auth_token = self.__get_msal_app().acquire_token_for_client(
resource=self.oauth_scope
)
return auth_token["access_token"]

def __get_msal_app(self):
if not self.app:
self.app = msal.ManagedIdentityClient(
self._managed_identity,
http_client=requests.Session(),
token_cache=ManagedIdentityAppCredentials.global_token_cache,
)
return self.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from logging import Logger

from msrest.authentication import Authentication

from .managedidentity_app_credentials import ManagedIdentityAppCredentials
from .microsoft_app_credentials import MicrosoftAppCredentials
from .service_client_credentials_factory import ServiceClientCredentialsFactory


class ManagedIdentityServiceClientCredentialsFactory(ServiceClientCredentialsFactory):
def __init__(self, app_id: str = None, *, logger: Logger = None) -> None:
self.app_id = app_id
self._logger = logger

async def is_valid_app_id(self, app_id: str) -> bool:
return app_id == self.app_id

async def is_authentication_disabled(self) -> bool:
return not self.app_id

async def create_credentials(
self,
app_id: str,
oauth_scope: str,
login_endpoint: str,
validate_authority: bool,
) -> Authentication:
if await self.is_authentication_disabled():
return MicrosoftAppCredentials.empty()

if not await self.is_valid_app_id(app_id):
raise Exception("Invalid app_id")

credentials = ManagedIdentityAppCredentials(app_id, oauth_scope)

return credentials
2 changes: 1 addition & 1 deletion libraries/botframework-connector/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ botbuilder-schema==4.16.0
requests==2.32.0
PyJWT==2.4.0
cryptography==42.0.4
msal==1.*
msal>=1.29.0
2 changes: 1 addition & 1 deletion libraries/botframework-connector/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# "requests>=2.23.0,<2.26",
"PyJWT>=2.4.0",
"botbuilder-schema==4.16.0",
"msal==1.*",
"msal>=1.29.0",
]

root = os.path.abspath(os.path.dirname(__file__))
Expand Down

0 comments on commit 579888d

Please sign in to comment.