blob: f547428dc7991efa38bdfeafe1264bdc28c60467 [file] [log] [blame]
Joe Gregorio20a5aa92011-04-01 17:44:25 -04001# Copyright (C) 2010 Google Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
Joe Gregorio695fdc12011-01-16 16:46:55 -050014
15"""An OAuth 2.0 client
16
17Tools for interacting with OAuth 2.0 protected
18resources.
19"""
20
21__author__ = 'jcgregorio@google.com (Joe Gregorio)'
22
23import copy
24import datetime
25import httplib2
26import logging
27import urllib
28import urlparse
29
30try: # pragma: no cover
31 import simplejson
32except ImportError: # pragma: no cover
33 try:
34 # Try to import from django, should work on App Engine
35 from django.utils import simplejson
36 except ImportError:
37 # Should work for Python2.6 and higher.
38 import json as simplejson
39
40try:
41 from urlparse import parse_qsl
42except ImportError:
43 from cgi import parse_qsl
44
45
46class Error(Exception):
47 """Base error for this module."""
48 pass
49
50
Joe Gregorioccc79542011-02-19 00:05:26 -050051class FlowExchangeError(Error):
Joe Gregorioca876e42011-02-22 19:39:42 -050052 """Error trying to exchange an authorization grant for an access token."""
Joe Gregorioccc79542011-02-19 00:05:26 -050053 pass
54
55
56class AccessTokenRefreshError(Error):
Joe Gregorioca876e42011-02-22 19:39:42 -050057 """Error trying to refresh an expired access token."""
Joe Gregorio695fdc12011-01-16 16:46:55 -050058 pass
59
60
Joe Gregorio3b79fa82011-02-17 11:47:17 -050061class AccessTokenCredentialsError(Error):
62 """Having only the access_token means no refresh is possible."""
Joe Gregorio695fdc12011-01-16 16:46:55 -050063 pass
64
65
66def _abstract():
67 raise NotImplementedError('You need to override this function')
68
69
70class Credentials(object):
71 """Base class for all Credentials objects.
72
73 Subclasses must define an authorize() method
74 that applies the credentials to an HTTP transport.
75 """
76
77 def authorize(self, http):
78 """Take an httplib2.Http instance (or equivalent) and
79 authorizes it for the set of credentials, usually by
80 replacing http.request() with a method that adds in
81 the appropriate headers and then delegates to the original
82 Http.request() method.
83 """
84 _abstract()
85
JacobMoshenko8e905102011-06-20 09:53:10 -040086
Joe Gregorio695fdc12011-01-16 16:46:55 -050087class Flow(object):
88 """Base class for all Flow objects."""
89 pass
90
91
Joe Gregoriodeeb0202011-02-15 14:49:57 -050092class Storage(object):
93 """Base class for all Storage objects.
94
95 Store and retrieve a single credential.
96 """
97
Joe Gregoriodeeb0202011-02-15 14:49:57 -050098 def get(self):
99 """Retrieve credential.
100
101 Returns:
Joe Gregorio06d852b2011-03-25 15:03:10 -0400102 oauth2client.client.Credentials
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500103 """
104 _abstract()
105
106 def put(self, credentials):
107 """Write a credential.
108
109 Args:
110 credentials: Credentials, the credentials to store.
111 """
112 _abstract()
113
114
Joe Gregorio695fdc12011-01-16 16:46:55 -0500115class OAuth2Credentials(Credentials):
116 """Credentials object for OAuth 2.0
117
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500118 Credentials can be applied to an httplib2.Http object using the authorize()
119 method, which then signs each request from that object with the OAuth 2.0
120 access token.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500121
122 OAuth2Credentials objects may be safely pickled and unpickled.
123 """
124
125 def __init__(self, access_token, client_id, client_secret, refresh_token,
126 token_expiry, token_uri, user_agent):
127 """Create an instance of OAuth2Credentials
128
129 This constructor is not usually called by the user, instead
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500130 OAuth2Credentials objects are instantiated by the OAuth2WebServerFlow.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500131
132 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500133 token_uri: string, URI of token endpoint.
134 client_id: string, client identifier.
135 client_secret: string, client secret.
136 access_token: string, access token.
137 token_expiry: datetime, when the access_token expires.
138 refresh_token: string, refresh token.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500139 user_agent: string, The HTTP User-Agent to provide for this application.
140
141
142 Notes:
143 store: callable, a callable that when passed a Credential
144 will store the credential back to where it came from.
145 This is needed to store the latest access_token if it
146 has expired and been refreshed.
147 """
148 self.access_token = access_token
149 self.client_id = client_id
150 self.client_secret = client_secret
151 self.refresh_token = refresh_token
152 self.store = None
153 self.token_expiry = token_expiry
154 self.token_uri = token_uri
155 self.user_agent = user_agent
156
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500157 # True if the credentials have been revoked or expired and can't be
158 # refreshed.
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500159 self._invalid = False
160
161 @property
162 def invalid(self):
163 """True if the credentials are invalid, such as being revoked."""
Joe Gregorioccc79542011-02-19 00:05:26 -0500164 return getattr(self, '_invalid', False)
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500165
Joe Gregorio695fdc12011-01-16 16:46:55 -0500166 def set_store(self, store):
167 """Set the storage for the credential.
168
169 Args:
170 store: callable, a callable that when passed a Credential
171 will store the credential back to where it came from.
172 This is needed to store the latest access_token if it
173 has expired and been refreshed.
174 """
175 self.store = store
176
177 def __getstate__(self):
178 """Trim the state down to something that can be pickled.
179 """
180 d = copy.copy(self.__dict__)
181 del d['store']
182 return d
183
184 def __setstate__(self, state):
185 """Reconstitute the state of the object from being pickled.
186 """
187 self.__dict__.update(state)
188 self.store = None
189
JacobMoshenko8e905102011-06-20 09:53:10 -0400190 def _generate_refresh_request_body(self):
191 """Generate the body that will be used in the refresh request
192 """
193 body = urllib.urlencode({
194 'grant_type': 'refresh_token',
195 'client_id': self.client_id,
196 'client_secret': self.client_secret,
197 'refresh_token': self.refresh_token,
198 })
199 return body
200
201 def _generate_refresh_request_headers(self):
202 """Generate the headers that will be used in the refresh request
203 """
204 headers = {
JacobMoshenko8e905102011-06-20 09:53:10 -0400205 'content-type': 'application/x-www-form-urlencoded',
206 }
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400207
208 if self.user_agent is not None:
209 headers['user-agent'] = self.user_agent
210
JacobMoshenko8e905102011-06-20 09:53:10 -0400211 return headers
212
Joe Gregorio695fdc12011-01-16 16:46:55 -0500213 def _refresh(self, http_request):
214 """Refresh the access_token using the refresh_token.
215
216 Args:
217 http: An instance of httplib2.Http.request
218 or something that acts like it.
219 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400220 body = self._generate_refresh_request_body()
221 headers = self._generate_refresh_request_headers()
222
Joe Gregorio205e73a2011-03-12 09:55:31 -0500223 logging.info("Refresing access_token")
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500224 resp, content = http_request(
225 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500226 if resp.status == 200:
227 # TODO(jcgregorio) Raise an error if loads fails?
228 d = simplejson.loads(content)
229 self.access_token = d['access_token']
230 self.refresh_token = d.get('refresh_token', self.refresh_token)
231 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500232 self.token_expiry = datetime.timedelta(
JacobMoshenko8e905102011-06-20 09:53:10 -0400233 seconds=int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500234 else:
235 self.token_expiry = None
236 if self.store is not None:
237 self.store(self)
238 else:
JacobMoshenko8e905102011-06-20 09:53:10 -0400239 # An {'error':...} response body means the token is expired or revoked,
240 # so we flag the credentials as such.
Joe Gregorioccc79542011-02-19 00:05:26 -0500241 logging.error('Failed to retrieve access token: %s' % content)
242 error_msg = 'Invalid response %s.' % resp['status']
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500243 try:
244 d = simplejson.loads(content)
245 if 'error' in d:
Joe Gregorioccc79542011-02-19 00:05:26 -0500246 error_msg = d['error']
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500247 self._invalid = True
Joe Gregorioa0a52e42011-02-17 17:13:26 -0500248 if self.store is not None:
249 self.store(self)
Joe Gregorio205e73a2011-03-12 09:55:31 -0500250 else:
JacobMoshenko8e905102011-06-20 09:53:10 -0400251 logging.warning(
252 "Unable to store refreshed credentials, no Storage provided.")
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500253 except:
254 pass
Joe Gregorioccc79542011-02-19 00:05:26 -0500255 raise AccessTokenRefreshError(error_msg)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500256
257 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500258 """Authorize an httplib2.Http instance with these credentials.
259
Joe Gregorio695fdc12011-01-16 16:46:55 -0500260 Args:
261 http: An instance of httplib2.Http
262 or something that acts like it.
263
264 Returns:
265 A modified instance of http that was passed in.
266
267 Example:
268
269 h = httplib2.Http()
270 h = credentials.authorize(h)
271
272 You can't create a new OAuth
273 subclass of httplib2.Authenication because
274 it never gets passed the absolute URI, which is
275 needed for signing. So instead we have to overload
276 'request' with a closure that adds in the
277 Authorization header and then calls the original version
278 of 'request()'.
279 """
280 request_orig = http.request
281
282 # The closure that will replace 'httplib2.Http.request'.
283 def new_request(uri, method='GET', body=None, headers=None,
284 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
285 connection_type=None):
JacobMoshenko8e905102011-06-20 09:53:10 -0400286 if not self.access_token:
287 logging.info("Attempting refresh to obtain initial access_token")
288 self._refresh(request_orig)
289
Joe Gregorio695fdc12011-01-16 16:46:55 -0500290 """Modify the request headers to add the appropriate
291 Authorization header."""
292 if headers == None:
293 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500294 headers['authorization'] = 'OAuth ' + self.access_token
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400295
296 if self.user_agent is not None:
297 if 'user-agent' in headers:
298 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
299 else:
300 headers['user-agent'] = self.user_agent
JacobMoshenko8e905102011-06-20 09:53:10 -0400301
Joe Gregorio695fdc12011-01-16 16:46:55 -0500302 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500303 redirections, connection_type)
JacobMoshenko8e905102011-06-20 09:53:10 -0400304
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500305 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500306 logging.info("Refreshing because we got a 401")
307 self._refresh(request_orig)
Joe Gregorioccc79542011-02-19 00:05:26 -0500308 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500309 return request_orig(uri, method, body, headers,
310 redirections, connection_type)
311 else:
312 return (resp, content)
313
314 http.request = new_request
315 return http
316
317
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500318class AccessTokenCredentials(OAuth2Credentials):
319 """Credentials object for OAuth 2.0
320
321 Credentials can be applied to an httplib2.Http object using the authorize()
322 method, which then signs each request from that object with the OAuth 2.0
323 access token. This set of credentials is for the use case where you have
324 acquired an OAuth 2.0 access_token from another place such as a JavaScript
325 client or another web application, and wish to use it from Python. Because
326 only the access_token is present it can not be refreshed and will in time
327 expire.
328
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500329 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500330
331 Usage:
332 credentials = AccessTokenCredentials('<an access token>',
333 'my-user-agent/1.0')
334 http = httplib2.Http()
335 http = credentials.authorize(http)
336
337 Exceptions:
338 AccessTokenCredentialsExpired: raised when the access_token expires or is
339 revoked.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500340 """
341
342 def __init__(self, access_token, user_agent):
343 """Create an instance of OAuth2Credentials
344
345 This is one of the few types if Credentials that you should contrust,
346 Credentials objects are usually instantiated by a Flow.
347
348 Args:
ade@google.com93a7f7c2011-02-23 16:00:37 +0000349 access_token: string, access token.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500350 user_agent: string, The HTTP User-Agent to provide for this application.
351
352 Notes:
353 store: callable, a callable that when passed a Credential
354 will store the credential back to where it came from.
355 """
356 super(AccessTokenCredentials, self).__init__(
357 access_token,
358 None,
359 None,
360 None,
361 None,
362 None,
363 user_agent)
364
365 def _refresh(self, http_request):
366 raise AccessTokenCredentialsError(
367 "The access_token is expired or invalid and can't be refreshed.")
368
JacobMoshenko8e905102011-06-20 09:53:10 -0400369
370class AssertionCredentials(OAuth2Credentials):
371 """Abstract Credentials object used for OAuth 2.0 assertion grants
372
373 This credential does not require a flow to instantiate because it represents
374 a two legged flow, and therefore has all of the required information to
375 generate and refresh its own access tokens. It must be subclassed to
376 generate the appropriate assertion string.
377
378 AssertionCredentials objects may be safely pickled and unpickled.
379 """
380
381 def __init__(self, assertion_type, user_agent,
382 token_uri='https://accounts.google.com/o/oauth2/token', **kwargs):
383 """Constructor for AssertionFlowCredentials
384
385 Args:
386 assertion_type: string, assertion type that will be declared to the auth
387 server
388 user_agent: string, The HTTP User-Agent to provide for this application.
389 token_uri: string, URI for token endpoint. For convenience
390 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
391 """
392 super(AssertionCredentials, self).__init__(
393 None,
394 None,
395 None,
396 None,
397 None,
398 token_uri,
399 user_agent)
400 self.assertion_type = assertion_type
401
402 def _generate_refresh_request_body(self):
403 assertion = self._generate_assertion()
404
405 body = urllib.urlencode({
406 'assertion_type': self.assertion_type,
407 'assertion': assertion,
408 'grant_type': "assertion",
409 })
410
411 return body
412
413 def _generate_assertion(self):
414 """Generate the assertion string that will be used in the access token
415 request.
416 """
417 _abstract()
418
419
Joe Gregorio695fdc12011-01-16 16:46:55 -0500420class OAuth2WebServerFlow(Flow):
421 """Does the Web Server Flow for OAuth 2.0.
422
423 OAuth2Credentials objects may be safely pickled and unpickled.
424 """
425
426 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregoriob577f992011-03-10 08:35:11 -0500427 auth_uri='https://accounts.google.com/o/oauth2/auth',
428 token_uri='https://accounts.google.com/o/oauth2/token',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500429 **kwargs):
430 """Constructor for OAuth2WebServerFlow
431
432 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500433 client_id: string, client identifier.
434 client_secret: string client secret.
435 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500436 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500437 auth_uri: string, URI for authorization endpoint. For convenience
438 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
439 token_uri: string, URI for token endpoint. For convenience
440 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500441 **kwargs: dict, The keyword arguments are all optional and required
442 parameters for the OAuth calls.
443 """
444 self.client_id = client_id
445 self.client_secret = client_secret
446 self.scope = scope
447 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500448 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500449 self.token_uri = token_uri
450 self.params = kwargs
451 self.redirect_uri = None
452
453 def step1_get_authorize_url(self, redirect_uri='oob'):
454 """Returns a URI to redirect to the provider.
455
456 Args:
457 redirect_uri: string, Either the string 'oob' for a non-web-based
458 application, or a URI that handles the callback from
459 the authorization server.
460
461 If redirect_uri is 'oob' then pass in the
462 generated verification code to step2_exchange,
463 otherwise pass in the query parameters received
464 at the callback uri to step2_exchange.
465 """
466
467 self.redirect_uri = redirect_uri
468 query = {
469 'response_type': 'code',
470 'client_id': self.client_id,
471 'redirect_uri': redirect_uri,
472 'scope': self.scope,
473 }
474 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500475 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500476 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
477 parts[4] = urllib.urlencode(query)
478 return urlparse.urlunparse(parts)
479
Joe Gregorioccc79542011-02-19 00:05:26 -0500480 def step2_exchange(self, code, http=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500481 """Exhanges a code for OAuth2Credentials.
482
483 Args:
484 code: string or dict, either the code as a string, or a dictionary
485 of the query parameters to the redirect_uri, which contains
486 the code.
Joe Gregorioccc79542011-02-19 00:05:26 -0500487 http: httplib2.Http, optional http instance to use to do the fetch
Joe Gregorio695fdc12011-01-16 16:46:55 -0500488 """
489
490 if not (isinstance(code, str) or isinstance(code, unicode)):
491 code = code['code']
492
493 body = urllib.urlencode({
494 'grant_type': 'authorization_code',
495 'client_id': self.client_id,
496 'client_secret': self.client_secret,
497 'code': code,
498 'redirect_uri': self.redirect_uri,
JacobMoshenko8e905102011-06-20 09:53:10 -0400499 'scope': self.scope,
Joe Gregorio695fdc12011-01-16 16:46:55 -0500500 })
501 headers = {
JacobMoshenko8e905102011-06-20 09:53:10 -0400502 'content-type': 'application/x-www-form-urlencoded',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500503 }
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400504
505 if self.user_agent is not None:
506 headers['user-agent'] = self.user_agent
507
Joe Gregorioccc79542011-02-19 00:05:26 -0500508 if http is None:
509 http = httplib2.Http()
JacobMoshenko8e905102011-06-20 09:53:10 -0400510 resp, content = http.request(self.token_uri, method='POST', body=body,
511 headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500512 if resp.status == 200:
513 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
514 d = simplejson.loads(content)
515 access_token = d['access_token']
516 refresh_token = d.get('refresh_token', None)
517 token_expiry = None
518 if 'expires_in' in d:
JacobMoshenko8e905102011-06-20 09:53:10 -0400519 token_expiry = datetime.datetime.now() + datetime.timedelta(
520 seconds=int(d['expires_in']))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500521
522 logging.info('Successfully retrieved access token: %s' % content)
JacobMoshenko8e905102011-06-20 09:53:10 -0400523 return OAuth2Credentials(access_token, self.client_id,
524 self.client_secret, refresh_token, token_expiry,
525 self.token_uri, self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500526 else:
527 logging.error('Failed to retrieve access token: %s' % content)
Joe Gregorioccc79542011-02-19 00:05:26 -0500528 error_msg = 'Invalid response %s.' % resp['status']
529 try:
530 d = simplejson.loads(content)
531 if 'error' in d:
532 error_msg = d['error']
533 except:
534 pass
535
536 raise FlowExchangeError(error_msg)