Skip to content

Commit 6e2e5e3

Browse files
committed
Merge branch 'core' into dev
2 parents 7ff35d7 + 81db8d2 commit 6e2e5e3

File tree

4 files changed

+517
-169
lines changed

4 files changed

+517
-169
lines changed

msal/application.py

Lines changed: 223 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,81 @@
1-
from .oauth2 import Client
1+
import time
2+
try: # Python 2
3+
from urlparse import urljoin
4+
except: # Python 3
5+
from urllib.parse import urljoin
6+
import logging
7+
8+
from oauth2cli import Client
29
from .authority import Authority
3-
from .request import decorate_scope
410
from .assertion import create_jwt_assertion
11+
from .token_cache import TokenCache
12+
13+
14+
def decorate_scope(
15+
scope, client_id,
16+
policy=None, # obsolete
17+
reserved_scope=frozenset(['openid', 'profile', 'offline_access'])):
18+
scope_set = set(scope) # Input scope is typically a list. Copy it to a set.
19+
if scope_set & reserved_scope:
20+
# These scopes are reserved for the API to provide good experience.
21+
# We could make the developer pass these and then if they do they will
22+
# come back asking why they don't see refresh token or user information.
23+
raise ValueError(
24+
"API does not accept {} value as user-provided scopes".format(
25+
reserved_scope))
26+
if client_id in scope_set:
27+
if len(scope_set) > 1:
28+
# We make developers pass their client id, so that they can express
29+
# the intent that they want the token for themselves (their own
30+
# app).
31+
# If we do not restrict them to passing only client id then they
32+
# could write code where they expect an id token but end up getting
33+
# access_token.
34+
raise ValueError("Client Id can only be provided as a single scope")
35+
decorated = set(reserved_scope) # Make a writable copy
36+
else:
37+
decorated = scope_set | reserved_scope
38+
return list(decorated)
539

640

741
class ClientApplication(object):
842

943
def __init__(
1044
self, client_id,
11-
authority="https://login.microsoftonline.com/common/",
12-
validate_authority=True):
45+
client_credential=None, authority=None, validate_authority=True,
46+
token_cache=None):
47+
"""
48+
:param client_credential: It can be a string containing client secret,
49+
or an X509 certificate container in this form:
50+
51+
{
52+
"certificate": "-----BEGIN PRIVATE KEY-----...",
53+
"thumbprint": "A1B2C3D4E5F6...",
54+
}
55+
"""
1356
self.client_id = client_id
14-
self.authority = Authority(authority, validate_authority)
57+
self.client_credential = client_credential
58+
self.authority = Authority(
59+
authority or "https://login.microsoftonline.com/common/",
60+
validate_authority)
1561
# Here the self.authority is not the same type as authority in input
62+
self.token_cache = token_cache or TokenCache()
63+
default_body = self._build_auth_parameters(
64+
self.client_credential,
65+
self.authority.token_endpoint, self.client_id)
66+
default_body["client_info"] = 1
67+
self.client = Client(
68+
self.client_id,
69+
configuration={
70+
"token_endpoint": self.authority.token_endpoint,
71+
"device_authorization_endpoint": urljoin(
72+
self.authority.token_endpoint, "devicecode"),
73+
},
74+
default_body=default_body,
75+
on_obtaining_tokens=self.token_cache.add,
76+
on_removing_rt=self.token_cache.remove_rt,
77+
on_updating_rt=self.token_cache.update_rt,
78+
)
1679

1780
@staticmethod
1881
def _build_auth_parameters(client_credential, token_endpoint, client_id):
@@ -27,97 +90,17 @@ def _build_auth_parameters(client_credential, token_endpoint, client_id):
2790
else:
2891
return {'client_secret': client_credential}
2992

30-
def acquire_token_silent(
31-
self, scope,
32-
user=None, # It can be a string as user id, or a User object
33-
authority=None, # See get_authorization_request_url()
34-
policy='',
35-
force_refresh=False, # To force refresh an Access Token (not a RT)
36-
**kwargs):
37-
the_authority = Authority(authority) if authority else self.authority
38-
refresh_token = kwargs.get('refresh_token') # For testing purpose
39-
response = Client(
40-
self.client_id, token_endpoint=the_authority.token_endpoint,
41-
default_body=self._build_auth_parameters(
42-
self.client_credential,
43-
the_authority.token_endpoint, self.client_id)
44-
).acquire_token_with_refresh_token(
45-
refresh_token,
46-
scope=decorate_scope(scope, self.client_id, policy),
47-
query={'p': policy} if policy else None)
48-
# TODO: refresh the refresh_token
49-
return response
50-
51-
52-
class PublicClientApplication(ClientApplication): # browser app or mobile app
53-
54-
## TBD: what if redirect_uri is not needed in the constructor at all?
55-
## Device Code flow does not need redirect_uri anyway.
56-
57-
# OUT_OF_BAND = "urn:ietf:wg:oauth:2.0:oob"
58-
# def __init__(self, client_id, redirect_uri=None, **kwargs):
59-
# super(PublicClientApplication, self).__init__(client_id, **kwargs)
60-
# self.redirect_uri = redirect_uri or self.OUT_OF_BAND
61-
62-
def acquire_token(
63-
self,
64-
scope,
65-
# additional_scope=None, # See also get_authorization_request_url()
66-
login_hint=None,
67-
ui_options=None,
68-
# user=None, # TBD: It exists in MSAL-dotnet but not in MSAL-Android
69-
policy='',
70-
authority=None, # See get_authorization_request_url()
71-
extra_query_params=None,
72-
):
73-
# It will handle the TWO round trips of Authorization Code Grant flow.
74-
raise NotImplemented()
75-
76-
# TODO: Support Device Code flow
77-
78-
79-
class ConfidentialClientApplication(ClientApplication): # server-side web app
80-
def __init__(
81-
self, client_id, client_credential, user_token_cache=None,
82-
# redirect_uri=None, # Experimental: Removed for now.
83-
# acquire_token_for_client() doesn't need it
84-
**kwargs):
85-
"""
86-
:param client_credential: It can be a string containing client secret,
87-
or an X509 certificate container in this form:
88-
89-
{
90-
"certificate": "-----BEGIN PRIVATE KEY-----...",
91-
"thumbprint": "A1B2C3D4E5F6...",
92-
}
93-
"""
94-
super(ConfidentialClientApplication, self).__init__(client_id, **kwargs)
95-
self.client_credential = client_credential
96-
self.user_token_cache = user_token_cache
97-
self.app_token_cache = None # TODO
98-
99-
def acquire_token_for_client(self, scope, policy=None):
100-
token_endpoint = self.authority.token_endpoint
101-
return Client(
102-
self.client_id, token_endpoint=token_endpoint,
103-
default_body=self._build_auth_parameters(
104-
self.client_credential, token_endpoint, self.client_id)
105-
).acquire_token_with_client_credentials(
106-
scope=scope, # This grant flow requires no scope decoration
107-
query={'p': policy} if policy else None)
108-
10993
def get_authorization_request_url(
11094
self,
11195
scope,
112-
# additional_scope=None, # Not yet implemented
96+
additional_scope=frozenset([]), # Not yet supported
11397
login_hint=None,
11498
state=None, # Recommended by OAuth2 for CSRF protection
115-
policy='',
11699
redirect_uri=None,
117100
authority=None, # By default, it will use self.authority;
118101
# Multi-tenant app can use new authority on demand
119-
extra_query_params=None, # None or a dictionary
120-
):
102+
response_type="code", # Can be "token" if you use Implicit Grant
103+
**kwargs):
121104
"""Constructs a URL for you to start a Authorization Code Grant.
122105
123106
:param scope: Scope refers to the resource that will be used in the
@@ -132,14 +115,13 @@ def get_authorization_request_url(
132115
"""
133116
the_authority = Authority(authority) if authority else self.authority
134117
client = Client(
135-
self.client_id,
136-
authorization_endpoint=the_authority.authorization_endpoint)
137-
return client.authorization_url(
118+
self.client_id, configuration={
119+
"authorization_endpoint": the_authority.authorization_endpoint})
120+
return client.build_auth_request_uri(
138121
response_type="code", # Using Authorization Code grant
139122
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
140-
scope=decorate_scope(scope, self.client_id, policy),
141-
policy=policy if policy else None,
142-
**(extra_query_params or {}))
123+
scope=decorate_scope(scope, self.client_id),
124+
)
143125

144126
def acquire_token_with_authorization_code(
145127
self,
@@ -149,20 +131,19 @@ def acquire_token_with_authorization_code(
149131
# REQUIRED, if the "redirect_uri" parameter was included in the
150132
# authorization request as described in Section 4.1.1, and their
151133
# values MUST be identical.
152-
policy=''
153134
):
154135
"""The second half of the Authorization Code Grant.
155136
156137
:param code: The authorization code returned from Authorization Server.
157138
:param scope:
158139
159140
If you requested user consent for multiple resources, here you will
160-
typically want to provide a subset of what you required in AC.
141+
typically want to provide a subset of what you required in AuthCode.
161142
162143
OAuth2 was designed mostly for singleton services,
163144
where tokens are always meant for the same resource and the only
164145
changes are in the scopes.
165-
In AAD, tokens can be issued for multiple 3rd parth resources.
146+
In AAD, tokens can be issued for multiple 3rd party resources.
166147
You can ask authorization code for multiple resources,
167148
but when you redeem it, the token is for only one intended
168149
recipient, called audience.
@@ -174,15 +155,153 @@ def acquire_token_with_authorization_code(
174155
# So in theory, you can omit scope here when you were working with only
175156
# one scope. But, MSAL decorates your scope anyway, so they are never
176157
# really empty.
177-
return Client(
178-
self.client_id, token_endpoint=self.authority.token_endpoint,
158+
assert isinstance(scope, list), "Invalid parameter type"
159+
return self.client.obtain_token_with_authorization_code(
160+
code, redirect_uri=redirect_uri,
161+
data={"scope": decorate_scope(scope, self.client_id)},
162+
)
163+
164+
def get_accounts(self):
165+
"""Returns a list of account objects that can later be used to find token.
166+
167+
Each account object is a dict containing a "username" field (among others)
168+
which can use to determine which account to use.
169+
"""
170+
# The following implementation finds accounts only from saved accounts,
171+
# but does NOT correlate them with saved RTs. It probably won't matter,
172+
# because in MSAL universe, there are always Accounts and RTs together.
173+
return self.token_cache.find(
174+
self.token_cache.CredentialType.ACCOUNT,
175+
query={"environment": self.authority.instance})
176+
177+
def acquire_token_silent(
178+
self, scope,
179+
account=None, # one of the account object returned by get_accounts()
180+
authority=None, # See get_authorization_request_url()
181+
force_refresh=False, # To force refresh an Access Token (not a RT)
182+
**kwargs):
183+
assert isinstance(scope, list), "Invalid parameter type"
184+
the_authority = Authority(authority) if authority else self.authority
185+
186+
if force_refresh == False:
187+
matches = self.token_cache.find(
188+
self.token_cache.CredentialType.ACCESS_TOKEN,
189+
target=scope,
190+
query={
191+
"client_id": self.client_id,
192+
"environment": the_authority.instance,
193+
"realm": the_authority.tenant,
194+
"home_account_id": (account or {}).get("home_account_id"),
195+
})
196+
now = time.time()
197+
for entry in matches:
198+
if entry["expires_on"] - now < 5*60:
199+
continue # Removal is not necessary, it will be overwritten
200+
return { # Mimic a real response
201+
"access_token": entry["secret"],
202+
"token_type": "Bearer",
203+
"expires_in": entry["expires_on"] - now,
204+
}
205+
206+
matches = self.token_cache.find(
207+
self.token_cache.CredentialType.REFRESH_TOKEN,
208+
# target=scope, # AAD RTs are scope-independent
209+
query={
210+
"client_id": self.client_id,
211+
"environment": the_authority.instance,
212+
"home_account_id": (account or {}).get("home_account_id"),
213+
# "realm": the_authority.tenant, # AAD RTs are tenant-independent
214+
})
215+
client = Client(
216+
self.client_id,
217+
configuration={"token_endpoint": the_authority.token_endpoint},
179218
default_body=self._build_auth_parameters(
180219
self.client_credential,
181-
self.authority.token_endpoint, self.client_id)
182-
).acquire_token_with_authorization_code(
183-
code, redirect_uri=redirect_uri,
184-
scope=decorate_scope(scope, self.client_id, policy),
185-
query={'p': policy} if policy else None)
220+
the_authority.token_endpoint, self.client_id),
221+
on_obtaining_tokens=self.token_cache.add,
222+
on_removing_rt=self.token_cache.remove_rt,
223+
on_updating_rt=self.token_cache.update_rt,
224+
)
225+
for entry in matches:
226+
response = client.obtain_token_with_refresh_token(
227+
entry, rt_getter=lambda token_item: token_item["secret"],
228+
scope=decorate_scope(scope, self.client_id))
229+
if "error" not in response:
230+
return response
231+
logging.debug(
232+
"Refresh failed. {error}: {error_description}".format(**response))
233+
234+
def initiate_device_flow(self, scope=None, **kwargs):
235+
return self.client.initiate_device_flow(
236+
scope=decorate_scope(scope, self.client_id) if scope else None,
237+
**kwargs)
238+
239+
def acquire_token_by_device_flow(
240+
self, flow, exit_condition=lambda: True, **kwargs):
241+
"""Obtain token by a device flow object, with optional polling effect.
242+
243+
Args:
244+
flow (dict):
245+
An object previously generated by initiate_device_flow(...).
246+
exit_condition (Callable):
247+
This method implements a loop to provide polling effect.
248+
The loop's exit condition is calculated by this callback.
249+
The default callback makes the loop run only once, i.e. no polling.
250+
"""
251+
return self.client.obtain_token_by_device_flow(
252+
flow, exit_condition=exit_condition,
253+
data={"code": flow["device_code"]}, # 2018-10-4 Hack:
254+
# during transition period,
255+
# service seemingly need both device_code and code parameter.
256+
**kwargs)
257+
258+
class PublicClientApplication(ClientApplication): # browser app or mobile app
259+
260+
## TBD: what if redirect_uri is not needed in the constructor at all?
261+
## Device Code flow does not need redirect_uri anyway.
262+
263+
# OUT_OF_BAND = "urn:ietf:wg:oauth:2.0:oob"
264+
# def __init__(self, client_id, redirect_uri=None, **kwargs):
265+
# super(PublicClientApplication, self).__init__(client_id, **kwargs)
266+
# self.redirect_uri = redirect_uri or self.OUT_OF_BAND
267+
268+
def acquire_token_with_username_password(
269+
self, username, password, scope=None, **kwargs):
270+
"""Gets a token for a given resource via user credentails."""
271+
return self.client.obtain_token_with_username_password(
272+
username, password,
273+
scope=decorate_scope(scope, self.client_id), **kwargs)
274+
275+
def acquire_token(
276+
self,
277+
scope,
278+
# additional_scope=None, # See also get_authorization_request_url()
279+
login_hint=None,
280+
ui_options=None,
281+
# user=None, # TBD: It exists in MSAL-dotnet but not in MSAL-Android
282+
policy='',
283+
authority=None, # See get_authorization_request_url()
284+
extra_query_params=None,
285+
):
286+
# It will handle the TWO round trips of Authorization Code Grant flow.
287+
raise NotImplemented()
288+
289+
290+
class ConfidentialClientApplication(ClientApplication): # server-side web app
291+
292+
def acquire_token_for_client(self, scope, force_refresh=False):
293+
"""Acquires token from the service for the confidential client.
294+
295+
:param force_refresh:
296+
This method attempts to look up valid access token in the cache.
297+
If this parameter is set to True,
298+
this method will ignore the access token in the cache
299+
and attempt to acquire new access token using client credentials
300+
"""
301+
# TODO: force_refresh will be implemented after the cache mechanism is ready
302+
return self.client.obtain_token_with_client_credentials(
303+
scope=scope, # This grant flow requires no scope decoration
304+
)
186305

187306
def acquire_token_on_behalf_of(
188307
self, user_assertion, scope, authority=None, policy=''):

0 commit comments

Comments
 (0)