Skip to content

Commit 5a62280

Browse files
committed
New object model for #320
1 parent e6b6701 commit 5a62280

File tree

5 files changed

+180
-164
lines changed

5 files changed

+180
-164
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from datetime import datetime, timedelta
5+
from urllib.parse import urlparse
6+
7+
import requests
8+
from msrest.authentication import Authentication
9+
10+
from botframework.connector.auth import AuthenticationConstants
11+
from botframework.connector.auth.authenticator import Authenticator
12+
13+
14+
class AppCredentials(Authentication):
15+
"""
16+
MicrosoftAppCredentials auth implementation and cache.
17+
"""
18+
19+
schema = "Bearer"
20+
21+
trustedHostNames = {
22+
# "state.botframework.com": datetime.max,
23+
# "state.botframework.azure.us": datetime.max,
24+
"api.botframework.com": datetime.max,
25+
"token.botframework.com": datetime.max,
26+
"api.botframework.azure.us": datetime.max,
27+
"token.botframework.azure.us": datetime.max,
28+
}
29+
cache = {}
30+
31+
def __init__(
32+
self, channel_auth_tenant: str = None, oauth_scope: str = None,
33+
):
34+
"""
35+
Initializes a new instance of MicrosoftAppCredentials class
36+
:param channel_auth_tenant: Optional. The oauth token tenant.
37+
"""
38+
tenant = (
39+
channel_auth_tenant
40+
if channel_auth_tenant
41+
else AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT
42+
)
43+
self.oauth_endpoint = (
44+
AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + tenant
45+
)
46+
self.oauth_scope = (
47+
oauth_scope or AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
48+
)
49+
50+
self.microsoft_app_id = None
51+
self.authenticator: Authenticator = None
52+
53+
@staticmethod
54+
def trust_service_url(service_url: str, expiration=None):
55+
"""
56+
Checks if the service url is for a trusted host or not.
57+
:param service_url: The service url.
58+
:param expiration: The expiration time after which this service url is not trusted anymore.
59+
:returns: True if the host of the service url is trusted; False otherwise.
60+
"""
61+
if expiration is None:
62+
expiration = datetime.now() + timedelta(days=1)
63+
host = urlparse(service_url).hostname
64+
if host is not None:
65+
AppCredentials.trustedHostNames[host] = expiration
66+
67+
@staticmethod
68+
def is_trusted_service(service_url: str) -> bool:
69+
"""
70+
Checks if the service url is for a trusted host or not.
71+
:param service_url: The service url.
72+
:returns: True if the host of the service url is trusted; False otherwise.
73+
"""
74+
host = urlparse(service_url).hostname
75+
if host is not None:
76+
return AppCredentials._is_trusted_url(host)
77+
return False
78+
79+
@staticmethod
80+
def _is_trusted_url(host: str) -> bool:
81+
expiration = AppCredentials.trustedHostNames.get(host, datetime.min)
82+
return expiration > (datetime.now() - timedelta(minutes=5))
83+
84+
# pylint: disable=arguments-differ
85+
def signed_session(self, session: requests.Session = None) -> requests.Session:
86+
"""
87+
Gets the signed session.
88+
:returns: Signed requests.Session object
89+
"""
90+
if not session:
91+
session = requests.Session()
92+
93+
# If there is no microsoft_app_id then there shouldn't be an
94+
# "Authorization" header on the outgoing activity.
95+
if not self.microsoft_app_id:
96+
session.headers.pop("Authorization", None)
97+
else:
98+
auth_token = self.get_token()
99+
header = "{} {}".format("Bearer", auth_token)
100+
session.headers["Authorization"] = header
101+
102+
return session
103+
104+
def get_token(self) -> str:
105+
return self._get_authenticator().acquire_token()["access_token"]
106+
107+
def _get_authenticator(self) -> Authenticator:
108+
if not self.authenticator:
109+
self.authenticator = self._build_authenticator()
110+
return self.authenticator
111+
112+
def _build_authenticator(self) -> Authenticator:
113+
"""
114+
Returns an appropriate Authenticator that is provided by a subclass.
115+
:return: An Authenticator object
116+
"""
117+
raise NotImplementedError()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
5+
class Authenticator:
6+
"""
7+
A provider of tokens
8+
"""
9+
10+
def acquire_token(self):
11+
"""
12+
Returns a token. The implementation is supplied by a subclass.
13+
:return: The string token
14+
"""
15+
raise NotImplementedError()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from abc import ABC
5+
6+
from msal import ConfidentialClientApplication
7+
8+
from botframework.connector.auth.authenticator import Authenticator
9+
10+
11+
class CredentialsAuthenticator(Authenticator, ABC):
12+
def __init__(self, app_id: str, app_password: str, authority: str, scope: str):
13+
self.app = ConfidentialClientApplication(
14+
client_id=app_id, client_credential=app_password, authority=authority
15+
)
16+
17+
self.scopes = [scope]
18+
19+
def acquire_token(self):
20+
# Firstly, looks up a token from cache
21+
# Since we are looking for token for the current app, NOT for an end user,
22+
# notice we give account parameter as None.
23+
auth_token = self.app.acquire_token_silent(self.scopes, account=None)
24+
if not auth_token:
25+
# No suitable token exists in cache. Let's get a new one from AAD.
26+
auth_token = self.app.acquire_token_for_client(scopes=self.scopes)
27+
return auth_token
Lines changed: 19 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,20 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
3-
from datetime import datetime, timedelta
4-
from urllib.parse import urlparse
53

6-
from adal import AuthenticationContext
7-
import requests
4+
from abc import ABC
85

9-
from msrest.authentication import Authentication
10-
from .authentication_constants import AuthenticationConstants
6+
from .app_credentials import AppCredentials
7+
from .authenticator import Authenticator
8+
from .credentials_authenticator import CredentialsAuthenticator
119

12-
# TODO: Decide to move this to Constants or viceversa (when porting OAuth)
13-
AUTH_SETTINGS = {
14-
"refreshEndpoint": "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token",
15-
"refreshScope": "https://api.botframework.com/.default",
16-
"botConnectorOpenIdMetadata": "https://login.botframework.com/v1/.well-known/openidconfiguration",
17-
"botConnectorIssuer": "https://api.botframework.com",
18-
"emulatorOpenIdMetadata": "https://login.microsoftonline.com/botframework.com/v2.0/"
19-
".well-known/openid-configuration",
20-
"emulatorAuthV31IssuerV1": "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
21-
"emulatorAuthV31IssuerV2": "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0",
22-
"emulatorAuthV32IssuerV1": "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/",
23-
"emulatorAuthV32IssuerV2": "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0",
24-
}
2510

26-
27-
class _OAuthResponse:
28-
def __init__(self):
29-
self.token_type = None
30-
self.expires_in = None
31-
self.access_token = None
32-
self.expiration_time = None
33-
34-
@staticmethod
35-
def from_json(json_values):
36-
result = _OAuthResponse()
37-
try:
38-
result.token_type = json_values["tokenType"]
39-
result.access_token = json_values["accessToken"]
40-
result.expires_in = json_values["expiresIn"]
41-
except KeyError:
42-
pass
43-
return result
44-
45-
46-
class MicrosoftAppCredentials(Authentication):
11+
class MicrosoftAppCredentials(AppCredentials, ABC):
4712
"""
48-
MicrosoftAppCredentials auth implementation and cache.
13+
MicrosoftAppCredentials auth implementation.
4914
"""
5015

51-
schema = "Bearer"
52-
53-
trustedHostNames = {
54-
"state.botframework.com": datetime.max,
55-
"api.botframework.com": datetime.max,
56-
"token.botframework.com": datetime.max,
57-
"state.botframework.azure.us": datetime.max,
58-
"api.botframework.azure.us": datetime.max,
59-
"token.botframework.azure.us": datetime.max,
60-
}
61-
cache = {}
16+
MICROSOFT_APP_ID = "MicrosoftAppId"
17+
MICROSOFT_PASSWORD = "MicrosoftPassword"
6218

6319
def __init__(
6420
self,
@@ -67,120 +23,20 @@ def __init__(
6723
channel_auth_tenant: str = None,
6824
oauth_scope: str = None,
6925
):
70-
"""
71-
Initializes a new instance of MicrosoftAppCredentials class
72-
:param app_id: The Microsoft app ID.
73-
:param app_password: The Microsoft app password.
74-
:param channel_auth_tenant: Optional. The oauth token tenant.
75-
"""
76-
# The configuration property for the Microsoft app ID.
26+
super().__init__(
27+
channel_auth_tenant=channel_auth_tenant, oauth_scope=oauth_scope
28+
)
7729
self.microsoft_app_id = app_id
78-
# The configuration property for the Microsoft app Password.
7930
self.microsoft_app_password = password
80-
tenant = (
81-
channel_auth_tenant
82-
if channel_auth_tenant
83-
else AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT
84-
)
85-
self.oauth_endpoint = (
86-
AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + tenant
87-
)
88-
self.oauth_scope = (
89-
oauth_scope or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER
90-
)
91-
self.token_cache_key = app_id + self.oauth_scope + "-cache" if app_id else None
92-
self.authentication_context = AuthenticationContext(self.oauth_endpoint)
9331

94-
# pylint: disable=arguments-differ
95-
def signed_session(self, session: requests.Session = None) -> requests.Session:
96-
"""
97-
Gets the signed session.
98-
:returns: Signed requests.Session object
32+
def _build_authenticator(self) -> Authenticator:
9933
"""
100-
if not session:
101-
session = requests.Session()
102-
103-
# If there is no microsoft_app_id and no self.microsoft_app_password, then there shouldn't
104-
# be an "Authorization" header on the outgoing activity.
105-
if not self.microsoft_app_id and not self.microsoft_app_password:
106-
session.headers.pop("Authorization", None)
107-
108-
else:
109-
auth_token = self.get_access_token()
110-
header = "{} {}".format("Bearer", auth_token)
111-
session.headers["Authorization"] = header
112-
113-
return session
114-
115-
def get_access_token(self, force_refresh: bool = False) -> str:
34+
Returns an Authenticator suitable for credential auth.
35+
:return: An Authenticator object
11636
"""
117-
Gets an OAuth access token.
118-
:param force_refresh: True to force a refresh of the token; or false to get
119-
a cached token if it exists.
120-
:returns: Access token string
121-
"""
122-
if self.microsoft_app_id and self.microsoft_app_password:
123-
if not force_refresh:
124-
# check the global cache for the token. If we have it, and it's valid, we're done.
125-
oauth_token = MicrosoftAppCredentials.cache.get(
126-
self.token_cache_key, None
127-
)
128-
if oauth_token is not None:
129-
# we have the token. Is it valid?
130-
if oauth_token.expiration_time > datetime.now():
131-
return oauth_token.access_token
132-
# We need to refresh the token, because:
133-
# 1. The user requested it via the force_refresh parameter
134-
# 2. We have it, but it's expired
135-
# 3. We don't have it in the cache.
136-
oauth_token = self.refresh_token()
137-
MicrosoftAppCredentials.cache.setdefault(self.token_cache_key, oauth_token)
138-
return oauth_token.access_token
139-
return ""
140-
141-
def refresh_token(self) -> _OAuthResponse:
142-
"""
143-
returns: _OAuthResponse
144-
"""
145-
146-
token = self.authentication_context.acquire_token_with_client_credentials(
147-
self.oauth_scope, self.microsoft_app_id, self.microsoft_app_password
148-
)
149-
150-
oauth_response = _OAuthResponse.from_json(token)
151-
oauth_response.expiration_time = datetime.now() + timedelta(
152-
seconds=(int(oauth_response.expires_in) - 300)
37+
return CredentialsAuthenticator(
38+
app_id=self.microsoft_app_id,
39+
app_password=self.microsoft_app_password,
40+
authority=self.oauth_endpoint,
41+
scope=self.oauth_scope,
15342
)
154-
155-
return oauth_response
156-
157-
@staticmethod
158-
def trust_service_url(service_url: str, expiration=None):
159-
"""
160-
Checks if the service url is for a trusted host or not.
161-
:param service_url: The service url.
162-
:param expiration: The expiration time after which this service url is not trusted anymore.
163-
:returns: True if the host of the service url is trusted; False otherwise.
164-
"""
165-
if expiration is None:
166-
expiration = datetime.now() + timedelta(days=1)
167-
host = urlparse(service_url).hostname
168-
if host is not None:
169-
MicrosoftAppCredentials.trustedHostNames[host] = expiration
170-
171-
@staticmethod
172-
def is_trusted_service(service_url: str) -> bool:
173-
"""
174-
Checks if the service url is for a trusted host or not.
175-
:param service_url: The service url.
176-
:returns: True if the host of the service url is trusted; False otherwise.
177-
"""
178-
host = urlparse(service_url).hostname
179-
if host is not None:
180-
return MicrosoftAppCredentials._is_trusted_url(host)
181-
return False
182-
183-
@staticmethod
184-
def _is_trusted_url(host: str) -> bool:
185-
expiration = MicrosoftAppCredentials.trustedHostNames.get(host, datetime.min)
186-
return expiration > (datetime.now() - timedelta(minutes=5))

libraries/botframework-connector/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ msrest==0.6.10
22
botbuilder-schema>=4.7.1
33
requests==2.22.0
44
PyJWT==1.5.3
5-
cryptography==2.8.0
5+
cryptography==2.8.0
6+
msal==1.1.0

0 commit comments

Comments
 (0)