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
29from .authority import Authority
3- from .request import decorate_scope
410from .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
741class 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