8000 Allow skills with no appId or password by mdrichardson · Pull Request #1406 · microsoft/botbuilder-python · GitHub
[go: up one dir, main page]

Skip to content

Allow skills with no appId or password #1406

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 9 commits into from
Oct 16, 2020
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 @@ -21,6 +21,7 @@
ClaimsIdentity,
CredentialProvider,
JwtTokenValidation,
SkillValidation,
)


Expand Down Expand Up @@ -469,18 +470,28 @@ async def on_upload_attachment(
raise BotActionNotImplementedError()

async def _authenticate(self, auth_header: str) -> ClaimsIdentity:
"""
Helper to authenticate the header.

This code is very similar to the code in JwtTokenValidation.authenticate_request,
we should move this code somewhere in that library when we refactor auth,
for now we keep it private to avoid adding more public static functions that we will need to deprecate later.
"""
if not auth_header:
is_auth_disabled = (
await self._credential_provider.is_authentication_disabled()
)
if is_auth_disabled:
# In the scenario where Auth is disabled, we still want to have the
# IsAuthenticated flag set in the ClaimsIdentity. To do this requires
# adding in an empty claim.
return ClaimsIdentity({}, True)
if not is_auth_disabled:
# No auth header. Auth is required. Request is not authorized.
raise PermissionError()

raise PermissionError()
# In the scenario where Auth is disabled, we still want to have the
# IsAuthenticated flag set in the ClaimsIdentity. To do this requires
# adding in an empty claim.
# Since ChannelServiceHandler calls are always a skill callback call, we set the skill claim too.
return SkillValidation.create_anonymous_skill_claim()

# Validate the header and extract claims.
return await JwtTokenValidation.validate_auth_header(
auth_header,
self._credential_provider,
Expand Down
46 changes: 46 additions & 0 deletions libraries/botbuilder-core/tests/test_channel_service_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import aiounittest
from botbuilder.core import ChannelServiceHandler
from botframework.connector.auth import (
AuthenticationConfiguration,
ClaimsIdentity,
SimpleCredentialProvider,
JwtTokenValidation,
AuthenticationConstants,
)
import botbuilder.schema


class TestChannelServiceHandler(ChannelServiceHandler):
def __init__(self):
self.claims_identity = None
ChannelServiceHandler.__init__(
self, SimpleCredentialProvider("", ""), AuthenticationConfiguration()
)

async def on_reply_to_activity(
self,
claims_identity: ClaimsIdentity,
conversation_id: str,
activity_id: str,
activity: botbuilder.schema.Activity,
) -> botbuilder.schema.ResourceResponse:
self.claims_identity = claims_identity
return botbuilder.schema.ResourceResponse()


class ChannelServiceHandlerTests(aiounittest.AsyncTestCase):
async def test_should_authenticate_anonymous_skill_claim(self):
sut = TestChannelServiceHandler()
await sut.handle_reply_to_activity(None, "123", "456", {})

assert (
sut.claims_identity.authentication_type
== AuthenticationConstants.ANONYMOUS_AUTH_TYPE
)
assert (
JwtTokenValidation.get_app_id_from_claims(sut.claims_identity.claims)
== AuthenticationConstants.ANONYMOUS_SKILL_APP_ID
)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ConversationReference,
ConversationAccount,
ChannelAccount,
RoleTypes,
)
from botframework.connector.auth import (
ChannelProvider,
Expand Down Expand Up @@ -97,7 +98,9 @@ async def post_activity(
activity.conversation.id = conversation_id
activity.service_url = service_url
if not activity.recipient:
activity.recipient = ChannelAccount()
activity.recipient = ChannelAccount(role=RoleTypes.skill)
else:
activity.recipient.role = RoleTypes.skill

status, content = await self._post_content(to_url, token, activity)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from unittest.mock import Mock

import aiounittest
from botbuilder.schema import ConversationAccount, ChannelAccount
from botbuilder.schema import ConversationAccount, ChannelAccount, RoleTypes
from botbuilder.integration.aiohttp import BotFrameworkHttpClient
from botframework.connector.auth import CredentialProvider, Activity

Expand Down Expand Up @@ -69,3 +69,4 @@ async def _mock_post_content(
)

assert activity.recipient.id == skill_recipient_id
assert activity.recipient.role is RoleTypes.skill
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class RoleTypes(str, Enum):

user = "user"
bot = "bot"
skill = "skill"


class ActivityTypes(str, Enum):
Expand Down
4 changes: 2 additions & 2 deletions libraries/botbuilder-schema/botbuilder/schema/_models_py3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1307,8 +1307,8 @@ class ConversationAccount(Model):
:param aad_object_id: This account's object ID within Azure Active
Directory (AAD)
:type aad_object_id: str
:param role: Role of the entity behind the account (Example: User, Bot,
etc.). Possible values include: 'user', 'bot'
:param role: Role of the entity behind the account (Example: User, Bot, Skill
etc.). Possible values include: 'user', 'bot', 'skill'
:type role: str or ~botframework.connector.models.RoleTypes
:param tenant_id: This conversation's tenant ID
:type tenant_id: str
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ def signed_session(self, session: requests.Session = None) -> requests.Session:
def _should_authorize(
self, session: requests.Session # pylint: disable=unused-argument
) -> bool:
return True
# We don't set the token if the AppId is not set, since it means that we are in an un-authenticated scenario.
return (
self.microsoft_app_id != AuthenticationConstants.ANONYMOUS_SKILL_APP_ID
and self.microsoft_app_id is not None
)

def get_access_token(self, force_refresh: bool = False) -> str:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,9 @@ class AuthenticationConstants(ABC):

# Service URL claim name. As used in Microsoft Bot Framework v3.1 auth.
SERVICE_URL_CLAIM = "serviceurl"

# AppId used for creating skill claims when there is no appId and password configured.
ANONYMOUS_SKILL_APP_ID = "AnonymousSkill"

# Indicates that ClaimsIdentity.authentication_type is anonymous (no app Id and password were provided).
ANONYMOUS_AUTH_TYPE = "anonymous"
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ def __init__(self, claim_type: str, value):


class ClaimsIdentity:
def __init__(self, claims: dict, is_authenticated: bool):
def __init__(
self, claims: dict, is_authenticated: bool, authentication_type: str = None
):
self.claims = claims
self.is_authenticated = is_authenticated
self.authentication_type = authentication_type

def get_claim_value(self, claim_type: str):
return self.claims.get(claim_type)
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# Licensed under the MIT License.
from typing import Dict, List, Union

from botbuilder.schema import Activity
from botbuilder.schema import Activity, RoleTypes

from ..channels import Channels
from .authentication_configuration import AuthenticationConfiguration
from .authentication_constants import AuthenticationConstants
from .emulator_validation import EmulatorValidation
Expand Down Expand Up @@ -43,14 +44,29 @@ async def authenticate_request(
"""
if not auth_header:
# No auth header was sent. We might be on the anonymous code path.
is_auth_disabled = await credentials.is_authentication_disabled()
if is_auth_disabled:
# We are on the anonymous code path.
return ClaimsIdentity({}, True)

# No Auth Header. Auth is required. Request is not authorized.
raise PermissionError("Unauthorized Access. Request is not authorized")

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")

# Check if the activity is for a skill call and is coming from the Emulator.
try:
if (
activity.channel_id == Channels.emulator
and activity.recipient.role == RoleTypes.skill
and activity.relates_to is not None
):
# Return an anonymous claim with an anonymous skill AppId
return SkillValidation.create_anonymous_skill_claim()
except AttributeError:
pass

# In the scenario where Auth is disabled, we still want to have the
# IsAuthenticated flag set in the ClaimsIdentity. To do this requires
# adding in an empty claim.
return ClaimsIdentity({}, True, AuthenticationConstants.ANONYMOUS_AUTH_TYPE)

# Validate the header and extract claims.
claims_identity = await JwtTokenValidation.validate_auth_header(
auth_header,
credentials,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ def is_skill_claim(claims: Dict[str, object]) -> bool:
if AuthenticationConstants.VERSION_CLAIM not in claims:
return False

if (
claims.get(AuthenticationConstants.APP_ID_CLAIM, None)
== AuthenticationConstants.ANONYMOUS_SKILL_APP_ID
):
return True

audience = claims.get(AuthenticationConstants.AUDIENCE_CLAIM)

# The audience is https://api.botframework.com and not an appId.
Expand Down Expand Up @@ -124,6 +130,20 @@ async def authenticate_channel_token(

return identity

@staticmethod
def create_anonymous_skill_claim():
"""
Creates a ClaimsIdentity for an anonymous (unauthenticated) skill.
:return ClaimsIdentity:
"""
return ClaimsIdentity(
{
AuthenticationConstants.APP_ID_CLAIM: AuthenticationConstants.ANONYMOUS_SKILL_APP_ID
},
True,
AuthenticationConstants.ANONYMOUS_AUTH_TYPE,
)

@staticmethod
async def _validate_identity(
identity: ClaimsIdentity, credentials: CredentialProvider
Expand Down
30 changes: 30 additions & 0 deletions libraries/botframework-connector/tests/test_app_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import aiounittest
from botframework.connector.auth import AppCredentials, AuthenticationConstants


class AppCredentialsTests(aiounittest.AsyncTestCase):
@staticmethod
def test_should_not_send_token_for_anonymous():
# AppID is None
app_creds_none = AppCredentials(app_id=None)
assert app_creds_none.signed_session().headers.get("Authorization") is None

# AppID is anonymous skill
app_creds_anon = AppCredentials(
app_id=AuthenticationConstants.ANONYMOUS_SKILL_APP_ID
)
assert app_creds_anon.signed_session().headers.get("Authorization") is None


def test_constructor():
should_default_to_channel_scope = AppCredentials()
assert (
should_default_to_channel_scope.oauth_scope
== AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
)

should_default_to_custom_scope = AppCredentials(oauth_scope="customScope")
assert should_default_to_custom_scope.oauth_scope == "customScope"
38 changes: 34 additions & 4 deletions libraries/botframework-connector/tests/test_auth.py
< 433 td class="blob-code blob-code-deletion js-file-line"> assert claims_principal.is_authenticated
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

import pytest

from botbuilder.schema import Activity
from botbuilder.schema import Activity, ConversationReference, ChannelAccount, RoleTypes
from botframework.connector import Channels
from botframework.connector.auth import (
AuthenticationConfiguration,
AuthenticationConstants,
Expand All @@ -21,6 +22,7 @@
GovernmentChannelValidation,
SimpleChannelProvider,
ChannelProvider,
AppCredentials,
)


Expand Down Expand Up @@ -262,7 +264,7 @@ async def test_channel_msa_header_valid_service_url_should_be_trusted(self):

await JwtTokenValidation.authenticate_request(activity, header, credentials)

assert MicrosoftAppCredentials.is_trusted_service(
assert AppCredentials.is_trusted_service(
"https://smba.trafficmanager.net/amer-client-ss.msg/"
)

Expand All @@ -289,6 +291,32 @@ async def test_channel_msa_header_invalid_service_url_should_not_be_trusted(self
"https://webchat.botframework.com/"
)

@pytest.mark.asyncio
# Tests with a valid Token and invalid service url and ensures that Service url is NOT added to
# Trusted service url list.
async def test_channel_authentication_disabled_and_skill_should_be_anonymous(self):
activity = Activity(
channel_id=Channels.emulator,
service_url="https://webchat.botframework.com/",
relates_to=ConversationReference(),
recipient=ChannelAccount(role=RoleTypes.skill),
)
header = ""
credentials = SimpleCredentialProvider("", "")

claims_principal = await JwtTokenValidation.authenticate_request(
activity, header, credentials
)

assert (
claims_principal.authentication_type
== AuthenticationConstants.ANONYMOUS_AUTH_TYPE
)
assert (
JwtTokenValidation.get_app_id_from_claims(claims_principal.claims)
== AuthenticationConstants.ANONYMOUS_SKILL_APP_ID
)

@pytest.mark.asyncio
async def test_channel_msa_header_from_user_specified_tenant(self):
activity = Activity(
Expand Down Expand Up @@ -318,8 +346,10 @@ async def test_channel_authentication_disabled_should_be_anonymous(self):
activity, header, credentials
)

assert not claims_principal.claims
assert (
claims_principal.authentication_type
== AuthenticationConstants.ANONYMOUS_AUTH_TYPE
)

@pytest.mark.asyncio
# Tests with no authentication header and makes sure the service URL is not added to the trusted list.
Expand Down
Loading
0