Skip to content

UserAssigned MSI support #2129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading