blob: c18a0ce55296db9433553825cc3331ab8397ce36 [file] [log] [blame]
Joe Gregorio695fdc12011-01-16 16:46:55 -05001# Copyright 2010 Google Inc. All Rights Reserved.
2
3"""An OAuth 2.0 client
4
5Tools for interacting with OAuth 2.0 protected
6resources.
7"""
8
9__author__ = 'jcgregorio@google.com (Joe Gregorio)'
10
11import copy
12import datetime
13import httplib2
14import logging
15import urllib
16import urlparse
17
18try: # pragma: no cover
19 import simplejson
20except ImportError: # pragma: no cover
21 try:
22 # Try to import from django, should work on App Engine
23 from django.utils import simplejson
24 except ImportError:
25 # Should work for Python2.6 and higher.
26 import json as simplejson
27
28try:
29 from urlparse import parse_qsl
30except ImportError:
31 from cgi import parse_qsl
32
33
34class Error(Exception):
35 """Base error for this module."""
36 pass
37
38
Joe Gregorioccc79542011-02-19 00:05:26 -050039class FlowExchangeError(Error):
Joe Gregorioca876e42011-02-22 19:39:42 -050040 """Error trying to exchange an authorization grant for an access token."""
Joe Gregorioccc79542011-02-19 00:05:26 -050041 pass
42
43
44class AccessTokenRefreshError(Error):
Joe Gregorioca876e42011-02-22 19:39:42 -050045 """Error trying to refresh an expired access token."""
Joe Gregorio695fdc12011-01-16 16:46:55 -050046 pass
47
48
Joe Gregorio3b79fa82011-02-17 11:47:17 -050049class AccessTokenCredentialsError(Error):
50 """Having only the access_token means no refresh is possible."""
Joe Gregorio695fdc12011-01-16 16:46:55 -050051 pass
52
53
54def _abstract():
55 raise NotImplementedError('You need to override this function')
56
57
58class Credentials(object):
59 """Base class for all Credentials objects.
60
61 Subclasses must define an authorize() method
62 that applies the credentials to an HTTP transport.
63 """
64
65 def authorize(self, http):
66 """Take an httplib2.Http instance (or equivalent) and
67 authorizes it for the set of credentials, usually by
68 replacing http.request() with a method that adds in
69 the appropriate headers and then delegates to the original
70 Http.request() method.
71 """
72 _abstract()
73
74class Flow(object):
75 """Base class for all Flow objects."""
76 pass
77
78
Joe Gregoriodeeb0202011-02-15 14:49:57 -050079class Storage(object):
80 """Base class for all Storage objects.
81
82 Store and retrieve a single credential.
83 """
84
85
86 def get(self):
87 """Retrieve credential.
88
89 Returns:
Joe Gregorio06d852b2011-03-25 15:03:10 -040090 oauth2client.client.Credentials
Joe Gregoriodeeb0202011-02-15 14:49:57 -050091 """
92 _abstract()
93
94 def put(self, credentials):
95 """Write a credential.
96
97 Args:
98 credentials: Credentials, the credentials to store.
99 """
100 _abstract()
101
102
Joe Gregorio695fdc12011-01-16 16:46:55 -0500103class OAuth2Credentials(Credentials):
104 """Credentials object for OAuth 2.0
105
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500106 Credentials can be applied to an httplib2.Http object using the authorize()
107 method, which then signs each request from that object with the OAuth 2.0
108 access token.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500109
110 OAuth2Credentials objects may be safely pickled and unpickled.
111 """
112
113 def __init__(self, access_token, client_id, client_secret, refresh_token,
114 token_expiry, token_uri, user_agent):
115 """Create an instance of OAuth2Credentials
116
117 This constructor is not usually called by the user, instead
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500118 OAuth2Credentials objects are instantiated by the OAuth2WebServerFlow.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500119
120 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500121 token_uri: string, URI of token endpoint.
122 client_id: string, client identifier.
123 client_secret: string, client secret.
124 access_token: string, access token.
125 token_expiry: datetime, when the access_token expires.
126 refresh_token: string, refresh token.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500127 user_agent: string, The HTTP User-Agent to provide for this application.
128
129
130 Notes:
131 store: callable, a callable that when passed a Credential
132 will store the credential back to where it came from.
133 This is needed to store the latest access_token if it
134 has expired and been refreshed.
135 """
136 self.access_token = access_token
137 self.client_id = client_id
138 self.client_secret = client_secret
139 self.refresh_token = refresh_token
140 self.store = None
141 self.token_expiry = token_expiry
142 self.token_uri = token_uri
143 self.user_agent = user_agent
144
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500145 # True if the credentials have been revoked or expired and can't be
146 # refreshed.
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500147 self._invalid = False
148
149 @property
150 def invalid(self):
151 """True if the credentials are invalid, such as being revoked."""
Joe Gregorioccc79542011-02-19 00:05:26 -0500152 return getattr(self, '_invalid', False)
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500153
Joe Gregorio695fdc12011-01-16 16:46:55 -0500154 def set_store(self, store):
155 """Set the storage for the credential.
156
157 Args:
158 store: callable, a callable that when passed a Credential
159 will store the credential back to where it came from.
160 This is needed to store the latest access_token if it
161 has expired and been refreshed.
162 """
163 self.store = store
164
165 def __getstate__(self):
166 """Trim the state down to something that can be pickled.
167 """
168 d = copy.copy(self.__dict__)
169 del d['store']
170 return d
171
172 def __setstate__(self, state):
173 """Reconstitute the state of the object from being pickled.
174 """
175 self.__dict__.update(state)
176 self.store = None
177
178 def _refresh(self, http_request):
179 """Refresh the access_token using the refresh_token.
180
181 Args:
182 http: An instance of httplib2.Http.request
183 or something that acts like it.
184 """
185 body = urllib.urlencode({
186 'grant_type': 'refresh_token',
187 'client_id': self.client_id,
188 'client_secret': self.client_secret,
189 'refresh_token' : self.refresh_token
190 })
191 headers = {
192 'user-agent': self.user_agent,
193 'content-type': 'application/x-www-form-urlencoded'
194 }
Joe Gregorio205e73a2011-03-12 09:55:31 -0500195 logging.info("Refresing access_token")
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500196 resp, content = http_request(
197 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500198 if resp.status == 200:
199 # TODO(jcgregorio) Raise an error if loads fails?
200 d = simplejson.loads(content)
201 self.access_token = d['access_token']
202 self.refresh_token = d.get('refresh_token', self.refresh_token)
203 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500204 self.token_expiry = datetime.timedelta(
205 seconds = int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500206 else:
207 self.token_expiry = None
208 if self.store is not None:
209 self.store(self)
210 else:
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500211 # An {'error':...} response body means the token is expired or revoked, so
212 # we flag the credentials as such.
Joe Gregorioccc79542011-02-19 00:05:26 -0500213 logging.error('Failed to retrieve access token: %s' % content)
214 error_msg = 'Invalid response %s.' % resp['status']
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500215 try:
216 d = simplejson.loads(content)
217 if 'error' in d:
Joe Gregorioccc79542011-02-19 00:05:26 -0500218 error_msg = d['error']
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500219 self._invalid = True
Joe Gregorioa0a52e42011-02-17 17:13:26 -0500220 if self.store is not None:
221 self.store(self)
Joe Gregorio205e73a2011-03-12 09:55:31 -0500222 else:
223 logging.warning("Unable to store refreshed credentials, no Storage provided.")
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500224 except:
225 pass
Joe Gregorioccc79542011-02-19 00:05:26 -0500226 raise AccessTokenRefreshError(error_msg)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500227
228 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500229 """Authorize an httplib2.Http instance with these credentials.
230
Joe Gregorio695fdc12011-01-16 16:46:55 -0500231 Args:
232 http: An instance of httplib2.Http
233 or something that acts like it.
234
235 Returns:
236 A modified instance of http that was passed in.
237
238 Example:
239
240 h = httplib2.Http()
241 h = credentials.authorize(h)
242
243 You can't create a new OAuth
244 subclass of httplib2.Authenication because
245 it never gets passed the absolute URI, which is
246 needed for signing. So instead we have to overload
247 'request' with a closure that adds in the
248 Authorization header and then calls the original version
249 of 'request()'.
250 """
251 request_orig = http.request
252
253 # The closure that will replace 'httplib2.Http.request'.
254 def new_request(uri, method='GET', body=None, headers=None,
255 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
256 connection_type=None):
257 """Modify the request headers to add the appropriate
258 Authorization header."""
259 if headers == None:
260 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500261 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500262 if 'user-agent' in headers:
263 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
264 else:
265 headers['user-agent'] = self.user_agent
266 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500267 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500268 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500269 logging.info("Refreshing because we got a 401")
270 self._refresh(request_orig)
Joe Gregorioccc79542011-02-19 00:05:26 -0500271 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500272 return request_orig(uri, method, body, headers,
273 redirections, connection_type)
274 else:
275 return (resp, content)
276
277 http.request = new_request
278 return http
279
280
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500281class AccessTokenCredentials(OAuth2Credentials):
282 """Credentials object for OAuth 2.0
283
284 Credentials can be applied to an httplib2.Http object using the authorize()
285 method, which then signs each request from that object with the OAuth 2.0
286 access token. This set of credentials is for the use case where you have
287 acquired an OAuth 2.0 access_token from another place such as a JavaScript
288 client or another web application, and wish to use it from Python. Because
289 only the access_token is present it can not be refreshed and will in time
290 expire.
291
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500292 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500293
294 Usage:
295 credentials = AccessTokenCredentials('<an access token>',
296 'my-user-agent/1.0')
297 http = httplib2.Http()
298 http = credentials.authorize(http)
299
300 Exceptions:
301 AccessTokenCredentialsExpired: raised when the access_token expires or is
302 revoked.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500303 """
304
305 def __init__(self, access_token, user_agent):
306 """Create an instance of OAuth2Credentials
307
308 This is one of the few types if Credentials that you should contrust,
309 Credentials objects are usually instantiated by a Flow.
310
311 Args:
ade@google.com93a7f7c2011-02-23 16:00:37 +0000312 access_token: string, access token.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500313 user_agent: string, The HTTP User-Agent to provide for this application.
314
315 Notes:
316 store: callable, a callable that when passed a Credential
317 will store the credential back to where it came from.
318 """
319 super(AccessTokenCredentials, self).__init__(
320 access_token,
321 None,
322 None,
323 None,
324 None,
325 None,
326 user_agent)
327
328 def _refresh(self, http_request):
329 raise AccessTokenCredentialsError(
330 "The access_token is expired or invalid and can't be refreshed.")
331
Joe Gregorio695fdc12011-01-16 16:46:55 -0500332class OAuth2WebServerFlow(Flow):
333 """Does the Web Server Flow for OAuth 2.0.
334
335 OAuth2Credentials objects may be safely pickled and unpickled.
336 """
337
338 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregoriob577f992011-03-10 08:35:11 -0500339 auth_uri='https://accounts.google.com/o/oauth2/auth',
340 token_uri='https://accounts.google.com/o/oauth2/token',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500341 **kwargs):
342 """Constructor for OAuth2WebServerFlow
343
344 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500345 client_id: string, client identifier.
346 client_secret: string client secret.
347 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500348 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500349 auth_uri: string, URI for authorization endpoint. For convenience
350 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
351 token_uri: string, URI for token endpoint. For convenience
352 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500353 **kwargs: dict, The keyword arguments are all optional and required
354 parameters for the OAuth calls.
355 """
356 self.client_id = client_id
357 self.client_secret = client_secret
358 self.scope = scope
359 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500360 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500361 self.token_uri = token_uri
362 self.params = kwargs
363 self.redirect_uri = None
364
365 def step1_get_authorize_url(self, redirect_uri='oob'):
366 """Returns a URI to redirect to the provider.
367
368 Args:
369 redirect_uri: string, Either the string 'oob' for a non-web-based
370 application, or a URI that handles the callback from
371 the authorization server.
372
373 If redirect_uri is 'oob' then pass in the
374 generated verification code to step2_exchange,
375 otherwise pass in the query parameters received
376 at the callback uri to step2_exchange.
377 """
378
379 self.redirect_uri = redirect_uri
380 query = {
381 'response_type': 'code',
382 'client_id': self.client_id,
383 'redirect_uri': redirect_uri,
384 'scope': self.scope,
385 }
386 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500387 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500388 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
389 parts[4] = urllib.urlencode(query)
390 return urlparse.urlunparse(parts)
391
Joe Gregorioccc79542011-02-19 00:05:26 -0500392 def step2_exchange(self, code, http=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500393 """Exhanges a code for OAuth2Credentials.
394
395 Args:
396 code: string or dict, either the code as a string, or a dictionary
397 of the query parameters to the redirect_uri, which contains
398 the code.
Joe Gregorioccc79542011-02-19 00:05:26 -0500399 http: httplib2.Http, optional http instance to use to do the fetch
Joe Gregorio695fdc12011-01-16 16:46:55 -0500400 """
401
402 if not (isinstance(code, str) or isinstance(code, unicode)):
403 code = code['code']
404
405 body = urllib.urlencode({
406 'grant_type': 'authorization_code',
407 'client_id': self.client_id,
408 'client_secret': self.client_secret,
409 'code': code,
410 'redirect_uri': self.redirect_uri,
411 'scope': self.scope
412 })
413 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500414 'user-agent': self.user_agent,
415 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500416 }
Joe Gregorioccc79542011-02-19 00:05:26 -0500417 if http is None:
418 http = httplib2.Http()
419 resp, content = http.request(self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500420 if resp.status == 200:
421 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
422 d = simplejson.loads(content)
423 access_token = d['access_token']
424 refresh_token = d.get('refresh_token', None)
425 token_expiry = None
426 if 'expires_in' in d:
427 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
428
429 logging.info('Successfully retrieved access token: %s' % content)
430 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500431 refresh_token, token_expiry, self.token_uri,
432 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500433 else:
434 logging.error('Failed to retrieve access token: %s' % content)
Joe Gregorioccc79542011-02-19 00:05:26 -0500435 error_msg = 'Invalid response %s.' % resp['status']
436 try:
437 d = simplejson.loads(content)
438 if 'error' in d:
439 error_msg = d['error']
440 except:
441 pass
442
443 raise FlowExchangeError(error_msg)