8000 Merge pull request #17 from Microsoft/southworks/auth-stack-updates · openvelora/botbuilder-python@d0d5f01 · GitHub
[go: up one dir, main page]

Skip to content

Commit d0d5f01

Browse files
authored
Merge pull request microsoft#17 from Microsoft/southworks/auth-stack-updates
Auth Stack updates
2 parents 05d8c6f + 68c30ea commit d0d5f01

27 files changed

+746
-85
lines changed

build-all.cmd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
cd .\libraries\botbuilder-schema\
2+
python .\setup.py install
3+
cd ..\botframework-connector\
4+
python .\setup.py install
-16 Bytes
Binary file not shown.

libraries/botbuilder-schema/setup.cfg

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
[bdist_wheel]
2-
universal=1
3-
azure-namespace-package=microsoft-botbuilder-schema
2+
universal=1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
# pylint: disable=missing-docstring
12
__import__('pkg_resources').declare_namespace(__name__)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
__import__('pkg_resources').declare_namespace(__name__)
1+
# pylint: disable=missing-docstring
2+
__import__('pkg_resources').declare_namespace(__name__)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,11 @@
88
# Changes may cause incorrect behavior and will be lost if the code is
99
# regenerated.
1010
# --------------------------------------------------------------------------
11+
# pylint: disable=missing-docstring
1112

12-
from .microsoftAuthentication import *
13+
from .microsoft_app_credentials import *
14+
from .jwt_token_validation import *
15+
from .credential_provider import *
16+
from .channel_validation import *
17+
from .emulator_validation import *
18+
from .jwt_token_extractor import *
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import asyncio
2+
3+
from .verify_options import VerifyOptions
4+
from .constants import Constants
5+
from .jwt_token_extractor import JwtTokenExtractor
6+
7+
class ChannelValidation:
8+
# This claim is ONLY used in the Channel Validation, and not in the emulator validation
9+
SERVICE_URL_CLAIM = 'serviceurl'
10+
11+
#
12+
# TO BOT FROM CHANNEL: Token validation parameters when connecting to a bot
13+
#
14+
TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions(
15+
issuer=[Constants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER],
16+
# Audience validation takes place manually in code.
17+
audience=None,
18+
clock_tolerance=5 * 60,
19+
ignore_expiration=False
20+
)
21+
22+
@staticmethod
23+
async def authenticate_token_service_url(auth_header, credentials, service_url):
24+
""" Validate the incoming Auth Header
25+
26+
Validate the incoming Auth Header as a token sent from the Bot Framework Service.
27+
A token issued by the Bot Framework emulator will FAIL this check.
28+
29+
:param auth_header: The raw HTTP header in the format: 'Bearer [longString]'
30+
:type auth_header: str
31+
:param credentials: The user defined set of valid credentials, such as the AppId.
32+
:type credentials: CredentialProvider
33+
:param service_url: Claim value that must match in the identity.
34+
:type service_url: str
35+
36+
:return: A valid ClaimsIdentity.
37+
:raises Exception:
38+
"""
39+
identity = await asyncio.ensure_future(
40+
ChannelValidation.authenticate_token(auth_header, credentials))
41+
42+
service_url_claim = identity.get_claim_value(ChannelValidation.SERVICE_URL_CLAIM)
43+
if service_url_claim != service_url:
44+
# Claim must match. Not Authorized.
45+
raise Exception('Unauthorized. service_url claim do not match.')
46+
47+
return identity
48+
49+
@staticmethod
50+
async def authenticate_token(auth_header, credentials):
51+
""" Validate the incoming Auth Header
52+
53+
Validate the incoming Auth Header as a token sent from the Bot Framework Service.
54+
A token issued by the Bot Framework emulator will FAIL this check.
55+
56+
:param auth_header: The raw HTTP header in the format: 'Bearer [longString]'
57+
:type auth_header: str
58+
:param credentials: The user defined set of valid credentials, such as the AppId.
59+
:type credentials: CredentialProvider
60+
61+
:return: A valid ClaimsIdentity.
62+
:raises Exception:
63+
"""
64+
token_extractor = JwtTokenExtractor(
65+
ChannelValidation.TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS,
66+
Constants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL,
67+
Constants.ALLOWED_SIGNING_ALGORITHMS)
68+
69+
identity = await asyncio.ensure_future(
70+
token_extractor.get_identity_from_auth_header(auth_header))
71+
if not identity:
72+
# No valid identity. Not Authorized.
73+
raise Exception('Unauthorized. No valid identity.')
74+
75+
if not identity.isAuthenticated:
76+
# The token is in some way invalid. Not Authorized.
77+
raise Exception('Unauthorized. Is not authenticated')
78+
79+
# Now check that the AppID in the claimset matches
80+
# what we're looking for. Note that in a multi-tenant bot, this value
81+
# comes from developer code that may be reaching out to a service, hence the
82+
# Async validation.
83+
84+
# Look for the "aud" claim, but only if issued from the Bot Framework
85+
if identity.get_claim_value(Constants.ISSUER_CLAIM) != Constants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER:
86+
# The relevant Audience Claim MUST be present. Not Authorized.
87+
raise Exception('Unauthorized. Audience Claim MUST be present.')
88+
89+
# The AppId from the claim in the token must match the AppId specified by the developer.
90+
# Note that the Bot Framework uses the Audience claim ("aud") to pass the AppID.
91+
aud_claim = identity.get_claim_value(Constants.AUDIENCE_CLAIM)
92+
is_valid_app_id = await asyncio.ensure_future(credentials.is_valid_appid(aud_claim or ""))
93+
if not is_valid_app_id:
94+
# The AppId is not valid or not present. Not Authorized.
95+
raise Exception('Unauthorized. Invalid AppId passed on token: ', aud_claim)
96+
97+
return identity
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class Claim:
2+
def __init__(self, claim_type, value):
3+
self.type = claim_type
4+
self.value = value
5+
6+
class ClaimsIdentity:
7+
def __init__(self, claims, isAuthenticated):
8+
self.claims = claims
9+
self.isAuthenticated = isAuthenticated
10+
11+
def get_claim_value(self, claim_type):
12+
return self.claims.get(claim_type)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class Constants: # pylint: disable=too-few-public-methods
2+
TO_BOT_FROM_CHANNEL_TOKEN_ISSUER = "https://api.botframework.com"
3+
4+
TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL = (
5+
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")
6+
TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL = (
7+
"https://login.botframework.com/v1/.well-known/openidconfiguration")
8+
9+
ALLOWED_SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512"]
10+
11+
AUTHORIZED_PARTY = "azp"
12+
AUDIENCE_CLAIM = "aud"
13+
ISSUER_CLAIM = "iss"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
class CredentialProvider:
2+
"""CredentialProvider.
3+
This class allows Bots to provide their own implemention
4+
of what is, and what is not, a valid appId and password.
5+
This is useful in the case of multi-tenant bots, where the bot
6+
may need to call out to a service to determine if a particular
7+
appid/password pair is valid.
8+
"""
9+
10+
async def is_valid_appid(self, app_id):
11+
"""Validate AppId.
12+
13+
This method is async to enable custom implementations
14+
that may need to call out to serviced to validate the appId / password pair.
15+
16+
:param app_id: bot appid
17+
:return: true if it is a valid AppId
18+
"""
19+
raise NotImplementedError
20+
21+
async def get_app_password(self, app_id):
22+
"""Get the app password for a given bot appId, if it is not a valid appId, return Null
23+
24+
This method is async to enable custom implementations
25+
that may need to call out to serviced to validate the appId / password pair.
26+
27+
:param app_id: bot appid
28+
:return: password or null for invalid appid
29+
"""
30+
raise NotImplementedError
31+
32+
async def is_authentication_disabled(self):
33+
"""Checks if bot authentication is disabled.
34+
35+
Return true if bot authentication is disabled.
36+
This method is async to enable custom implementations
37+
that may need to call out to serviced to validate the appId / password pair.
38+
39+
:return: true if bot authentication is disabled.
40+
"""
41+
raise NotImplementedError
42+
43+
class SimpleCredentialProvider(CredentialProvider):
44+
def __init__(self, app_id, password):
45+
self.app_id = app_id
46+
self.password = password
47+
48+
async def is_valid_appid(self, app_id):
49+
return self.app_id == app_id
50+
51+
async def get_app_password(self, app_id):
52+
return self.password if self.app_id == app_id else None
53+
54+
async def is_authentication_disabled(self):
55+
return not self.app_id
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import asyncio
2+
import jwt
3+
4+
from .jwt_token_extractor import JwtTokenExtractor
5+
from .verify_options import VerifyOptions
6+
from .constants import Constants
7+
8+
class EmulatorValidation:
9+
APP_ID_CLAIM = "appid"
10+
VERSION_CLAIM = "ver"
11+
12+
TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS = VerifyOptions(
13+
issuer=[
14+
# Auth v3.1, 1.0 token
15+
'https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/',
16+
# Auth v3.1, 2.0 token
17+
'https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0',
18+
# Auth v3.2, 1.0 token
19+
'https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/',
20+
# Auth v3.2, 2.0 token
21+
'https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0',
22+
# ???
23+
'https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/'
24+
],
25+
audience=None,
26+
clock_tolerance=5 * 60,
27+
ignore_expiration=False
28+
)
29+
30+
@staticmethod
31+
def is_token_from_emulator(auth_header):
32+
""" Determines if a given Auth header is from the Bot Framework Emulator
33+
34+
:param auth_header: Bearer Token, in the 'Bearer [Long String]' Format.
35+
:type auth_header: str
36+
37+
:return: True, if the token was issued by the Emulator. Otherwise, false.
38+
"""
39+
# The Auth Header generally looks like this:
40+
# "Bearer eyJ0e[...Big Long String...]XAiO"
41+
if not auth_header:
42+
# No token. Can't be an emulator token.
43+
return False
44+
45+
parts = auth_header.split(' ')
46+
if len(parts) != 2:
47+
# Emulator tokens MUST have exactly 2 parts.
48+
# If we don't have 2 parts, it's not an emulator token
49+
return False
50+
51+
auth_scheme = parts[0]
52+
bearer_token = parts[1]
53+
54+
# We now have an array that should be:
55+
# [0] = "Bearer"
56+
# [1] = "[Big Long String]"
57+
if auth_scheme != 'Bearer':
58+
# The scheme from the emulator MUST be "Bearer"
59+
return False
60+
61+
# Parse the Big Long String into an actual token.
62+
token = jwt.decode(bearer_token, verify=False)
63+
if not token:
64+
return False
65+
66+
# Is there an Issuer?
67+
issuer = token['iss']
68+
if not issuer:
69+
# No Issuer, means it's not from the Emulator.
70+
return False
71+
72+
# Is the token issues by a source we consider to be the emulator?
73+
issuer_list = EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS.issuer
74+
if issuer_list and not issuer in issuer_list:
75+
# Not a Valid Issuer. This is NOT a Bot Framework Emulator Token.
76+
return False
77+
78+
# The Token is from the Bot Framework Emulator. Success!
79+
return True
80+
81+
@staticmethod
82+
async def authenticate_emulator_token(auth_header, credentials):
83+
""" Validate the incoming Auth Header
84+
85+
Validate the incoming Auth Header as a token sent from the Bot Framework Service.
86+
A token issued by the Bot Framework emulator will FAIL this check.
87+
88+
:param auth_header: The raw HTTP header in the format: 'Bearer [longString]'
89+
:type auth_header: str
90+
:param credentials: The user defined set of valid credentials, such as the AppId.
91+
:type credentials: CredentialProvider
92+
93+
:return: A valid ClaimsIdentity.
94+
:raises Exception:
95+
"""
96+
token_extractor = JwtTokenExtractor(
97+
EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS,
98+
Constants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL,
99+
Constants.ALLOWED_SIGNING_ALGORITHMS)
100+
101+
identity = await asyncio.ensure_future(
102+
token_extractor.get_identity_from_auth_header(auth_header))
103+
if not identity:
104+
# No valid identity. Not Authorized.
105+
raise Exception('Unauthorized. No valid identity.')
106+
107+
if not identity.isAuthenticated:
108+
# The token is in some way invalid. Not Authorized.
109+
raise Exception('Unauthorized. Is not authenticated')
110+
111+
# Now check that the AppID in the claimset matches
112+
# what we're looking for. Note that in a multi-tenant bot, this value
113+
# comes from developer code that may be reaching out to a service, hence the
114+
# Async validation.
115+
version_claim = identity.get_claim_value(EmulatorValidation.VERSION_CLAIM)
116+
if version_claim is None:
117+
raise Exception('Unauthorized. "ver" claim is required on Emulator Tokens.')
118+
119+
app_id = ''
120+
121+
# The Emulator, depending on Version, sends the AppId via either the
122+
# appid claim (Version 1) or the Authorized Party claim (Version 2).
123+
if not version_claim or version_claim == '1.0':
124+
# either no Version or a version of "1.0" means we should look for
125+
# the claim in the "appid" claim.
126+
app_id_claim = identity.get_claim_value(EmulatorValidation.APP_ID_CLAIM)
127+
if not app_id_claim:
128+
# No claim around AppID. Not Authorized.
129+
raise Exception('Unauthorized. '
130+
'"appid" claim is required on Emulator Token version "1.0".')
131+
132+
app_id = app_id_claim
133+
elif version_claim == '2.0':
134+
# Emulator, "2.0" puts the AppId in the "azp" claim.
135+
app_authz_claim = identity.get_claim_value(Constants.AUTHORIZED_PARTY)
136+
if not app_authz_claim:
137+
# No claim around AppID. Not Authorized.
138+
raise Exception('Unauthorized. '
139+
'"azp" claim is required on Emulator Token version "2.0".')
140+
141+
app_id = app_authz_claim
142+
else:
143+
# Unknown Version. Not Authorized.
144+
raise Exception('Unauthorized. Unknown Emulator Token version ', version_claim, '.')
145+
146+
is_valid_app_id = await asyncio.ensure_future(credentials.is_valid_appid(app_id))
147+
if not is_valid_app_id:
148+
raise Exception('Unauthorized. Invalid AppId passed on token: ', app_id)
149+
150+
return identity

0 commit comments

Comments
 (0)
0