oauth2_session.py
21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
from __future__ import unicode_literals
import logging
from oauthlib.common import generate_token, urldecode
from oauthlib.oauth2 import WebApplicationClient, InsecureTransportError
from oauthlib.oauth2 import LegacyApplicationClient
from oauthlib.oauth2 import TokenExpiredError, is_secure_transport
import requests
log = logging.getLogger(__name__)
class TokenUpdated(Warning):
def __init__(self, token):
super(TokenUpdated, self).__init__()
self.token = token
class OAuth2Session(requests.Session):
"""Versatile OAuth 2 extension to :class:`requests.Session`.
Supports any grant type adhering to :class:`oauthlib.oauth2.Client` spec
including the four core OAuth 2 grants.
Can be used to create authorization urls, fetch tokens and access protected
resources using the :class:`requests.Session` interface you are used to.
- :class:`oauthlib.oauth2.WebApplicationClient` (default): Authorization Code Grant
- :class:`oauthlib.oauth2.MobileApplicationClient`: Implicit Grant
- :class:`oauthlib.oauth2.LegacyApplicationClient`: Password Credentials Grant
- :class:`oauthlib.oauth2.BackendApplicationClient`: Client Credentials Grant
Note that the only time you will be using Implicit Grant from python is if
you are driving a user agent able to obtain URL fragments.
"""
def __init__(
self,
client_id=None,
client=None,
auto_refresh_url=None,
auto_refresh_kwargs=None,
scope=None,
redirect_uri=None,
token=None,
state=None,
token_updater=None,
**kwargs
):
"""Construct a new OAuth 2 client session.
:param client_id: Client id obtained during registration
:param client: :class:`oauthlib.oauth2.Client` to be used. Default is
WebApplicationClient which is useful for any
hosted application but not mobile or desktop.
:param scope: List of scopes you wish to request access to
:param redirect_uri: Redirect URI you registered as callback
:param token: Token dictionary, must include access_token
and token_type.
:param state: State string used to prevent CSRF. This will be given
when creating the authorization url and must be supplied
when parsing the authorization response.
Can be either a string or a no argument callable.
:auto_refresh_url: Refresh token endpoint URL, must be HTTPS. Supply
this if you wish the client to automatically refresh
your access tokens.
:auto_refresh_kwargs: Extra arguments to pass to the refresh token
endpoint.
:token_updater: Method with one argument, token, to be used to update
your token database on automatic token refresh. If not
set a TokenUpdated warning will be raised when a token
has been refreshed. This warning will carry the token
in its token argument.
:param kwargs: Arguments to pass to the Session constructor.
"""
super(OAuth2Session, self).__init__(**kwargs)
self._client = client or WebApplicationClient(client_id, token=token)
self.token = token or {}
self.scope = scope
self.redirect_uri = redirect_uri
self.state = state or generate_token
self._state = state
self.auto_refresh_url = auto_refresh_url
self.auto_refresh_kwargs = auto_refresh_kwargs or {}
self.token_updater = token_updater
# Ensure that requests doesn't do any automatic auth. See #278.
# The default behavior can be re-enabled by setting auth to None.
self.auth = lambda r: r
# Allow customizations for non compliant providers through various
# hooks to adjust requests and responses.
self.compliance_hook = {
"access_token_response": set(),
"refresh_token_response": set(),
"protected_request": set(),
}
def new_state(self):
"""Generates a state string to be used in authorizations."""
try:
self._state = self.state()
log.debug("Generated new state %s.", self._state)
except TypeError:
self._state = self.state
log.debug("Re-using previously supplied state %s.", self._state)
return self._state
@property
def client_id(self):
return getattr(self._client, "client_id", None)
@client_id.setter
def client_id(self, value):
self._client.client_id = value
@client_id.deleter
def client_id(self):
del self._client.client_id
@property
def token(self):
return getattr(self._client, "token", None)
@token.setter
def token(self, value):
self._client.token = value
self._client.populate_token_attributes(value)
@property
def access_token(self):
return getattr(self._client, "access_token", None)
@access_token.setter
def access_token(self, value):
self._client.access_token = value
@access_token.deleter
def access_token(self):
del self._client.access_token
@property
def authorized(self):
"""Boolean that indicates whether this session has an OAuth token
or not. If `self.authorized` is True, you can reasonably expect
OAuth-protected requests to the resource to succeed. If
`self.authorized` is False, you need the user to go through the OAuth
authentication dance before OAuth-protected requests to the resource
will succeed.
"""
return bool(self.access_token)
def authorization_url(self, url, state=None, **kwargs):
"""Form an authorization URL.
:param url: Authorization endpoint url, must be HTTPS.
:param state: An optional state string for CSRF protection. If not
given it will be generated for you.
:param kwargs: Extra parameters to include.
:return: authorization_url, state
"""
state = state or self.new_state()
return (
self._client.prepare_request_uri(
url,
redirect_uri=self.redirect_uri,
scope=self.scope,
state=state,
**kwargs
),
state,
)
def fetch_token(
self,
token_url,
code=None,
authorization_response=None,
body="",
auth=None,
username=None,
password=None,
method="POST",
force_querystring=False,
timeout=None,
headers=None,
verify=True,
proxies=None,
include_client_id=None,
client_secret=None,
**kwargs
):
"""Generic method for fetching an access token from the token endpoint.
If you are using the MobileApplicationClient you will want to use
`token_from_fragment` instead of `fetch_token`.
The current implementation enforces the RFC guidelines.
:param token_url: Token endpoint URL, must use HTTPS.
:param code: Authorization code (used by WebApplicationClients).
:param authorization_response: Authorization response URL, the callback
URL of the request back to you. Used by
WebApplicationClients instead of code.
:param body: Optional application/x-www-form-urlencoded body to add the
include in the token request. Prefer kwargs over body.
:param auth: An auth tuple or method as accepted by `requests`.
:param username: Username required by LegacyApplicationClients to appear
in the request body.
:param password: Password required by LegacyApplicationClients to appear
in the request body.
:param method: The HTTP method used to make the request. Defaults
to POST, but may also be GET. Other methods should
be added as needed.
:param force_querystring: If True, force the request body to be sent
in the querystring instead.
:param timeout: Timeout of the request in seconds.
:param headers: Dict to default request headers with.
:param verify: Verify SSL certificate.
:param proxies: The `proxies` argument is passed onto `requests`.
:param include_client_id: Should the request body include the
`client_id` parameter. Default is `None`,
which will attempt to autodetect. This can be
forced to always include (True) or never
include (False).
:param client_secret: The `client_secret` paired to the `client_id`.
This is generally required unless provided in the
`auth` tuple. If the value is `None`, it will be
omitted from the request, however if the value is
an empty string, an empty string will be sent.
:param kwargs: Extra parameters to include in the token request.
:return: A token dict
"""
if not is_secure_transport(token_url):
raise InsecureTransportError()
if not code and authorization_response:
self._client.parse_request_uri_response(
authorization_response, state=self._state
)
code = self._client.code
elif not code and isinstance(self._client, WebApplicationClient):
code = self._client.code
if not code:
raise ValueError(
"Please supply either code or " "authorization_response parameters."
)
# Earlier versions of this library build an HTTPBasicAuth header out of
# `username` and `password`. The RFC states, however these attributes
# must be in the request body and not the header.
# If an upstream server is not spec compliant and requires them to
# appear as an Authorization header, supply an explicit `auth` header
# to this function.
# This check will allow for empty strings, but not `None`.
#
# References
# 4.3.2 - Resource Owner Password Credentials Grant
# https://tools.ietf.org/html/rfc6749#section-4.3.2
if isinstance(self._client, LegacyApplicationClient):
if username is None:
raise ValueError(
"`LegacyApplicationClient` requires both the "
"`username` and `password` parameters."
)
if password is None:
raise ValueError(
"The required parameter `username` was supplied, "
"but `password` was not."
)
# merge username and password into kwargs for `prepare_request_body`
if username is not None:
kwargs["username"] = username
if password is not None:
kwargs["password"] = password
# is an auth explicitly supplied?
if auth is not None:
# if we're dealing with the default of `include_client_id` (None):
# we will assume the `auth` argument is for an RFC compliant server
# and we should not send the `client_id` in the body.
# This approach allows us to still force the client_id by submitting
# `include_client_id=True` along with an `auth` object.
if include_client_id is None:
include_client_id = False
# otherwise we may need to create an auth header
else:
# since we don't have an auth header, we MAY need to create one
# it is possible that we want to send the `client_id` in the body
# if so, `include_client_id` should be set to True
# otherwise, we will generate an auth header
if include_client_id is not True:
client_id = self.client_id
if client_id:
log.debug(
'Encoding `client_id` "%s" with `client_secret` '
"as Basic auth credentials.",
client_id,
)
client_secret = client_secret if client_secret is not None else ""
auth = requests.auth.HTTPBasicAuth(client_id, client_secret)
if include_client_id:
# this was pulled out of the params
# it needs to be passed into prepare_request_body
if client_secret is not None:
kwargs["client_secret"] = client_secret
body = self._client.prepare_request_body(
code=code,
body=body,
redirect_uri=self.redirect_uri,
include_client_id=include_client_id,
**kwargs
)
headers = headers or {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
}
self.token = {}
request_kwargs = {}
if method.upper() == "POST":
request_kwargs["params" if force_querystring else "data"] = dict(
urldecode(body)
)
elif method.upper() == "GET":
request_kwargs["params"] = dict(urldecode(body))
else:
raise ValueError("The method kwarg must be POST or GET.")
r = self.request(
method=method,
url=token_url,
timeout=timeout,
headers=headers,
auth=auth,
verify=verify,
proxies=proxies,
**request_kwargs
)
log.debug("Request to fetch token completed with status %s.", r.status_code)
log.debug("Request url was %s", r.request.url)
log.debug("Request headers were %s", r.request.headers)
log.debug("Request body was %s", r.request.body)
log.debug("Response headers were %s and content %s.", r.headers, r.text)
log.debug(
"Invoking %d token response hooks.",
len(self.compliance_hook["access_token_response"]),
)
for hook in self.compliance_hook["access_token_response"]:
log.debug("Invoking hook %s.", hook)
r = hook(r)
self._client.parse_request_body_response(r.text, scope=self.scope)
self.token = self._client.token
log.debug("Obtained token %s.", self.token)
return self.token
def token_from_fragment(self, authorization_response):
"""Parse token from the URI fragment, used by MobileApplicationClients.
:param authorization_response: The full URL of the redirect back to you
:return: A token dict
"""
self._client.parse_request_uri_response(
authorization_response, state=self._state
)
self.token = self._client.token
return self.token
def refresh_token(
self,
token_url,
refresh_token=None,
body="",
auth=None,
timeout=None,
headers=None,
verify=True,
proxies=None,
**kwargs
):
"""Fetch a new access token using a refresh token.
:param token_url: The token endpoint, must be HTTPS.
:param refresh_token: The refresh_token to use.
:param body: Optional application/x-www-form-urlencoded body to add the
include in the token request. Prefer kwargs over body.
:param auth: An auth tuple or method as accepted by `requests`.
:param timeout: Timeout of the request in seconds.
:param headers: A dict of headers to be used by `requests`.
:param verify: Verify SSL certificate.
:param proxies: The `proxies` argument will be passed to `requests`.
:param kwargs: Extra parameters to include in the token request.
:return: A token dict
"""
if not token_url:
raise ValueError("No token endpoint set for auto_refresh.")
if not is_secure_transport(token_url):
raise InsecureTransportError()
refresh_token = refresh_token or self.token.get("refresh_token")
log.debug(
"Adding auto refresh key word arguments %s.", self.auto_refresh_kwargs
)
kwargs.update(self.auto_refresh_kwargs)
body = self._client.prepare_refresh_body(
body=body, refresh_token=refresh_token, scope=self.scope, **kwargs
)
log.debug("Prepared refresh token request body %s", body)
if headers is None:
headers = {
"Accept": "application/json",
"Content-Type": ("application/x-www-form-urlencoded;charset=UTF-8"),
}
r = self.post(
token_url,
data=dict(urldecode(body)),
auth=auth,
timeout=timeout,
headers=headers,
verify=verify,
withhold_token=True,
proxies=proxies,
)
log.debug("Request to refresh token completed with status %s.", r.status_code)
log.debug("Response headers were %s and content %s.", r.headers, r.text)
log.debug(
"Invoking %d token response hooks.",
len(self.compliance_hook["refresh_token_response"]),
)
for hook in self.compliance_hook["refresh_token_response"]:
log.debug("Invoking hook %s.", hook)
r = hook(r)
self.token = self._client.parse_request_body_response(r.text, scope=self.scope)
if not "refresh_token" in self.token:
log.debug("No new refresh token given. Re-using old.")
self.token["refresh_token"] = refresh_token
return self.token
def request(
self,
method,
url,
data=None,
headers=None,
withhold_token=False,
client_id=None,
client_secret=None,
**kwargs
):
"""Intercept all requests and add the OAuth 2 token if present."""
if not is_secure_transport(url):
raise InsecureTransportError()
if self.token and not withhold_token:
log.debug(
"Invoking %d protected resource request hooks.",
len(self.compliance_hook["protected_request"]),
)
for hook in self.compliance_hook["protected_request"]:
log.debug("Invoking hook %s.", hook)
url, headers, data = hook(url, headers, data)
log.debug("Adding token %s to request.", self.token)
try:
url, headers, data = self._client.add_token(
url, http_method=method, body=data, headers=headers
)
# Attempt to retrieve and save new access token if expired
except TokenExpiredError:
if self.auto_refresh_url:
log.debug(
"Auto refresh is set, attempting to refresh at %s.",
self.auto_refresh_url,
)
# We mustn't pass auth twice.
auth = kwargs.pop("auth", None)
if client_id and client_secret and (auth is None):
log.debug(
'Encoding client_id "%s" with client_secret as Basic auth credentials.',
client_id,
)
auth = requests.auth.HTTPBasicAuth(client_id, client_secret)
token = self.refresh_token(
self.auto_refresh_url, auth=auth, **kwargs
)
if self.token_updater:
log.debug(
"Updating token to %s using %s.", token, self.token_updater
)
self.token_updater(token)
url, headers, data = self._client.add_token(
url, http_method=method, body=data, headers=headers
)
else:
raise TokenUpdated(token)
else:
raise
log.debug("Requesting url %s using method %s.", url, method)
log.debug("Supplying headers %s and data %s", headers, data)
log.debug("Passing through key word arguments %s.", kwargs)
return super(OAuth2Session, self).request(
method, url, headers=headers, data=data, **kwargs
)
def register_compliance_hook(self, hook_type, hook):
"""Register a hook for request/response tweaking.
Available hooks are:
access_token_response invoked before token parsing.
refresh_token_response invoked before refresh token parsing.
protected_request invoked before making a request.
If you find a new hook is needed please send a GitHub PR request
or open an issue.
"""
if hook_type not in self.compliance_hook:
raise ValueError(
"Hook type %s is not in %s.", hook_type, self.compliance_hook
)
self.compliance_hook[hook_type].add(hook)