Skip to content

Auth changes for skills #420

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 10 commits into from
Nov 25, 2019
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
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from typing import List


class AuthenticationConfiguration:
def __init__(self, required_endorsements: List[str] = None):
self.required_endorsements = required_endorsements or []
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio

from .authentication_configuration import AuthenticationConfiguration
from .verify_options import VerifyOptions
from .constants import Constants
from .jwt_token_extractor import JwtTokenExtractor
Expand Down Expand Up @@ -30,6 +31,7 @@ async def authenticate_channel_token_with_service_url(
credentials: CredentialProvider,
service_url: str,
channel_id: str,
auth_configuration: AuthenticationConfiguration = None,
) -> ClaimsIdentity:
""" Validate the incoming Auth Header

Expand All @@ -48,7 +50,7 @@ async def authenticate_channel_token_with_service_url(
"""
identity = await asyncio.ensure_future(
ChannelValidation.authenticate_channel_token(
auth_header, credentials, channel_id
auth_header, credentials, channel_id, auth_configuration
)
)

Expand All @@ -63,7 +65,10 @@ async def authenticate_channel_token_with_service_url(

@staticmethod
async def authenticate_channel_token(
auth_header: str, credentials: CredentialProvider, channel_id: str
auth_header: str,
credentials: CredentialProvider,
channel_id: str,
auth_configuration: AuthenticationConfiguration = None,
) -> ClaimsIdentity:
""" Validate the incoming Auth Header

Expand All @@ -78,6 +83,7 @@ async def authenticate_channel_token(
:return: A valid ClaimsIdentity.
:raises Exception:
"""
auth_configuration = auth_configuration or AuthenticationConfiguration()
metadata_endpoint = (
ChannelValidation.open_id_metadata_endpoint
if ChannelValidation.open_id_metadata_endpoint
Expand All @@ -91,7 +97,9 @@ async def authenticate_channel_token(
)

identity = await asyncio.ensure_future(
token_extractor.get_identity_from_auth_header(auth_header, channel_id)
token_extractor.get_identity_from_auth_header(
auth_header, channel_id, auth_configuration.required_endorsements
)
)

return await ChannelValidation.validate_identity(identity, credentials)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import asyncio
import jwt

Expand Down Expand Up @@ -44,27 +47,14 @@ def is_token_from_emulator(auth_header: str) -> bool:

:return: True, if the token was issued by the Emulator. Otherwise, false.
"""
# The Auth Header generally looks like this:
# "Bearer eyJ0e[...Big Long String...]XAiO"
if not auth_header:
# No token. Can't be an emulator token.
return False
from .jwt_token_validation import ( # pylint: disable=import-outside-toplevel
JwtTokenValidation,
)

parts = auth_header.split(" ")
if len(parts) != 2:
# Emulator tokens MUST have exactly 2 parts.
# If we don't have 2 parts, it's not an emulator token
if not JwtTokenValidation.is_valid_token_format(auth_header):
return False

auth_scheme = parts[0]
bearer_token = parts[1]

# We now have an array that should be:
# [0] = "Bearer"
# [1] = "[Big Long String]"
if auth_scheme != "Bearer":
# The scheme from the emulator MUST be "Bearer"
return False
bearer_token = auth_header.split(" ")[1]

# Parse the Big Long String into an actual token.
token = jwt.decode(bearer_token, verify=False)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

class EndorsementsValidator:
@staticmethod
def validate(channel_id: str, endorsements: List[str]):
def validate(expected_endorsement: str, endorsements: List[str]):
# If the Activity came in and doesn't have a Channel ID then it's making no
# assertions as to who endorses it. This means it should pass.
if not channel_id:
if not expected_endorsement:
return True

if endorsements is None:
Expand All @@ -31,5 +31,5 @@ def validate(channel_id: str, endorsements: List[str]):
# of scope, tokens from WebChat have about 10 endorsements, and
# tokens coming from Teams have about 20.

endorsement_present = channel_id in endorsements
endorsement_present = expected_endorsement in endorsements
return endorsement_present
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from abc import ABC

from .authentication_configuration import AuthenticationConfiguration
from .authentication_constants import AuthenticationConstants
from .channel_validation import ChannelValidation
from .claims_identity import ClaimsIdentity
Expand All @@ -26,6 +27,7 @@ async def authenticate_channel_token(
credentials: CredentialProvider,
channel_id: str,
channel_service: str,
auth_configuration: AuthenticationConfiguration = None,
) -> ClaimsIdentity:
endpoint = (
ChannelValidation.open_id_metadata_endpoint
Expand All @@ -41,7 +43,7 @@ async def authenticate_channel_token(
)

identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header(
auth_header, channel_id
auth_header, channel_id, auth_configuration.required_endorsements
)
return await EnterpriseChannelValidation.validate_identity(
identity, credentials
Expand All @@ -54,9 +56,10 @@ async def authenticate_channel_token_with_service_url(
service_url: str,
channel_id: str,
channel_service: str,
auth_configuration: AuthenticationConfiguration = None,
) -> ClaimsIdentity:
identity: ClaimsIdentity = await EnterpriseChannelValidation.authenticate_channel_token(
auth_header, credentials, channel_id, channel_service
auth_header, credentials, channel_id, channel_service, auth_configuration
)

service_url_claim: str = identity.get_claim_value(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from abc import ABC

from .authentication_configuration import AuthenticationConfiguration
from .authentication_constants import AuthenticationConstants
from .claims_identity import ClaimsIdentity
from .credential_provider import CredentialProvider
Expand All @@ -24,8 +25,12 @@ class GovernmentChannelValidation(ABC):

@staticmethod
async def authenticate_channel_token(
auth_header: str, credentials: CredentialProvider, channel_id: str
auth_header: str,
credentials: CredentialProvider,
channel_id: str,
auth_configuration: AuthenticationConfiguration = None,
) -> ClaimsIdentity:
auth_configuration = auth_configuration or AuthenticationConfiguration()
endpoint = (
GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT
if GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT
Expand All @@ -38,7 +43,7 @@ async def authenticate_channel_token(
)

identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header(
auth_header, channel_id
auth_header, channel_id, auth_configuration.required_endorsements
)
return await GovernmentChannelValidation.validate_identity(
identity, credentials
Expand All @@ -50,9 +55,10 @@ async def authenticate_channel_token_with_service_url(
credentials: CredentialProvider,
service_url: str,
channel_id: str,
auth_configuration: AuthenticationConfiguration = None,
) -> ClaimsIdentity:
identity: ClaimsIdentity = await GovernmentChannelValidation.authenticate_channel_token(
auth_header, credentials, channel_id
auth_header, credentials, channel_id, auth_configuration
)

service_url_claim: str = identity.get_claim_value(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import json
from datetime import datetime, timedelta
from typing import List
import requests
from jwt.algorithms import RSAAlgorithm
import jwt
Expand Down Expand Up @@ -33,17 +34,23 @@ def get_open_id_metadata(metadata_url: str):
return metadata

async def get_identity_from_auth_header(
self, auth_header: str, channel_id: str
self, auth_header: str, channel_id: str, required_endorsements: List[str] = None
) -> ClaimsIdentity:
if not auth_header:
return None
parts = auth_header.split(" ")
if len(parts) == 2:
return await self.get_identity(parts[0], parts[1], channel_id)
return await self.get_identity(
parts[0], parts[1], channel_id, required_endorsements
)
return None

async def get_identity(
self, schema: str, parameter: str, channel_id
self,
schema: str,
parameter: str,
channel_id: str,
required_endorsements: List[str] = None,
) -> ClaimsIdentity:
# No header in correct scheme or no token
if schema != "Bearer" or not parameter:
Expand All @@ -54,7 +61,9 @@ async def get_identity(
return None

try:
return await self._validate_token(parameter, channel_id)
return await self._validate_token(
parameter, channel_id, required_endorsements
)
except Exception as error:
raise error

Expand All @@ -64,27 +73,46 @@ def _has_allowed_issuer(self, jwt_token: str) -> bool:
if issuer in self.validation_parameters.issuer:
return True

return issuer is self.validation_parameters.issuer
return issuer == self.validation_parameters.issuer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix!


async def _validate_token(self, jwt_token: str, channel_id: str) -> ClaimsIdentity:
async def _validate_token(
self, jwt_token: str, channel_id: str, required_endorsements: List[str] = None
) -> ClaimsIdentity:
required_endorsements = required_endorsements or []
headers = jwt.get_unverified_header(jwt_token)

# Update the signing tokens from the last refresh
key_id = headers.get("kid", None)
metadata = await self.open_id_metadata.get(key_id)

if key_id and metadata.endorsements:
# Verify that channelId is included in endorsements
if not EndorsementsValidator.validate(channel_id, metadata.endorsements):
raise Exception("Could not validate endorsement key")

# Verify that additional endorsements are satisfied.
# If no additional endorsements are expected, the requirement is satisfied as well
for endorsement in required_endorsements:
if not EndorsementsValidator.validate(
endorsement, metadata.endorsements
):
raise Exception("Could not validate endorsement key")

if headers.get("alg", None) not in self.validation_parameters.algorithms:
raise Exception("Token signing algorithm not in allowed list")

options = {
"verify_aud": False,
"verify_exp": not self.validation_parameters.ignore_expiration,
}
decoded_payload = jwt.decode(jwt_token, metadata.public_key, options=options)

decoded_payload = jwt.decode(
jwt_token,
metadata.public_key,
leeway=self.validation_parameters.clock_tolerance,
options=options,
)

claims = ClaimsIdentity(decoded_payload, True)

return claims
Expand Down
Loading