blob: c8d0941b8a1d42311ed00f5ba9da6778d8cb144e [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):
40 """Error occurred during request."""
41 pass
42
43
44class AccessTokenRefreshError(Error):
Joe Gregorio695fdc12011-01-16 16:46:55 -050045 """Error occurred during request."""
46 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:
90 apiclient.oauth.Credentials
91 """
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 Gregorio7c22ab22011-02-16 15:32:39 -0500195 resp, content = http_request(
196 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500197 if resp.status == 200:
198 # TODO(jcgregorio) Raise an error if loads fails?
199 d = simplejson.loads(content)
200 self.access_token = d['access_token']
201 self.refresh_token = d.get('refresh_token', self.refresh_token)
202 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500203 self.token_expiry = datetime.timedelta(
204 seconds = int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500205 else:
206 self.token_expiry = None
207 if self.store is not None:
208 self.store(self)
209 else:
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500210 # An {'error':...} response body means the token is expired or revoked, so
211 # we flag the credentials as such.
Joe Gregorioccc79542011-02-19 00:05:26 -0500212 logging.error('Failed to retrieve access token: %s' % content)
213 error_msg = 'Invalid response %s.' % resp['status']
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500214 try:
215 d = simplejson.loads(content)
216 if 'error' in d:
Joe Gregorioccc79542011-02-19 00:05:26 -0500217 error_msg = d['error']
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500218 self._invalid = True
Joe Gregorioa0a52e42011-02-17 17:13:26 -0500219 if self.store is not None:
220 self.store(self)
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500221 except:
222 pass
Joe Gregorioccc79542011-02-19 00:05:26 -0500223 raise AccessTokenRefreshError(error_msg)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500224
225 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500226 """Authorize an httplib2.Http instance with these credentials.
227
Joe Gregorio695fdc12011-01-16 16:46:55 -0500228 Args:
229 http: An instance of httplib2.Http
230 or something that acts like it.
231
232 Returns:
233 A modified instance of http that was passed in.
234
235 Example:
236
237 h = httplib2.Http()
238 h = credentials.authorize(h)
239
240 You can't create a new OAuth
241 subclass of httplib2.Authenication because
242 it never gets passed the absolute URI, which is
243 needed for signing. So instead we have to overload
244 'request' with a closure that adds in the
245 Authorization header and then calls the original version
246 of 'request()'.
247 """
248 request_orig = http.request
249
250 # The closure that will replace 'httplib2.Http.request'.
251 def new_request(uri, method='GET', body=None, headers=None,
252 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
253 connection_type=None):
254 """Modify the request headers to add the appropriate
255 Authorization header."""
256 if headers == None:
257 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500258 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500259 if 'user-agent' in headers:
260 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
261 else:
262 headers['user-agent'] = self.user_agent
263 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500264 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500265 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500266 logging.info("Refreshing because we got a 401")
267 self._refresh(request_orig)
Joe Gregorioccc79542011-02-19 00:05:26 -0500268 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500269 return request_orig(uri, method, body, headers,
270 redirections, connection_type)
271 else:
272 return (resp, content)
273
274 http.request = new_request
275 return http
276
277
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500278class AccessTokenCredentials(OAuth2Credentials):
279 """Credentials object for OAuth 2.0
280
281 Credentials can be applied to an httplib2.Http object using the authorize()
282 method, which then signs each request from that object with the OAuth 2.0
283 access token. This set of credentials is for the use case where you have
284 acquired an OAuth 2.0 access_token from another place such as a JavaScript
285 client or another web application, and wish to use it from Python. Because
286 only the access_token is present it can not be refreshed and will in time
287 expire.
288
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500289 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500290
291 Usage:
292 credentials = AccessTokenCredentials('<an access token>',
293 'my-user-agent/1.0')
294 http = httplib2.Http()
295 http = credentials.authorize(http)
296
297 Exceptions:
298 AccessTokenCredentialsExpired: raised when the access_token expires or is
299 revoked.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500300 """
301
302 def __init__(self, access_token, user_agent):
303 """Create an instance of OAuth2Credentials
304
305 This is one of the few types if Credentials that you should contrust,
306 Credentials objects are usually instantiated by a Flow.
307
308 Args:
309 token_uri: string, URI of token endpoint.
310 user_agent: string, The HTTP User-Agent to provide for this application.
311
312 Notes:
313 store: callable, a callable that when passed a Credential
314 will store the credential back to where it came from.
315 """
316 super(AccessTokenCredentials, self).__init__(
317 access_token,
318 None,
319 None,
320 None,
321 None,
322 None,
323 user_agent)
324
325 def _refresh(self, http_request):
326 raise AccessTokenCredentialsError(
327 "The access_token is expired or invalid and can't be refreshed.")
328
Joe Gregorio695fdc12011-01-16 16:46:55 -0500329class OAuth2WebServerFlow(Flow):
330 """Does the Web Server Flow for OAuth 2.0.
331
332 OAuth2Credentials objects may be safely pickled and unpickled.
333 """
334
335 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500336 auth_uri='https://www.google.com/accounts/o8/oauth2/authorization',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500337 token_uri='https://www.google.com/accounts/o8/oauth2/token',
338 **kwargs):
339 """Constructor for OAuth2WebServerFlow
340
341 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500342 client_id: string, client identifier.
343 client_secret: string client secret.
344 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500345 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500346 auth_uri: string, URI for authorization endpoint. For convenience
347 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
348 token_uri: string, URI for token endpoint. For convenience
349 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500350 **kwargs: dict, The keyword arguments are all optional and required
351 parameters for the OAuth calls.
352 """
353 self.client_id = client_id
354 self.client_secret = client_secret
355 self.scope = scope
356 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500357 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500358 self.token_uri = token_uri
359 self.params = kwargs
360 self.redirect_uri = None
361
362 def step1_get_authorize_url(self, redirect_uri='oob'):
363 """Returns a URI to redirect to the provider.
364
365 Args:
366 redirect_uri: string, Either the string 'oob' for a non-web-based
367 application, or a URI that handles the callback from
368 the authorization server.
369
370 If redirect_uri is 'oob' then pass in the
371 generated verification code to step2_exchange,
372 otherwise pass in the query parameters received
373 at the callback uri to step2_exchange.
374 """
375
376 self.redirect_uri = redirect_uri
377 query = {
378 'response_type': 'code',
379 'client_id': self.client_id,
380 'redirect_uri': redirect_uri,
381 'scope': self.scope,
382 }
383 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500384 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500385 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
386 parts[4] = urllib.urlencode(query)
387 return urlparse.urlunparse(parts)
388
Joe Gregorioccc79542011-02-19 00:05:26 -0500389 def step2_exchange(self, code, http=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500390 """Exhanges a code for OAuth2Credentials.
391
392 Args:
393 code: string or dict, either the code as a string, or a dictionary
394 of the query parameters to the redirect_uri, which contains
395 the code.
Joe Gregorioccc79542011-02-19 00:05:26 -0500396 http: httplib2.Http, optional http instance to use to do the fetch
Joe Gregorio695fdc12011-01-16 16:46:55 -0500397 """
398
399 if not (isinstance(code, str) or isinstance(code, unicode)):
400 code = code['code']
401
402 body = urllib.urlencode({
403 'grant_type': 'authorization_code',
404 'client_id': self.client_id,
405 'client_secret': self.client_secret,
406 'code': code,
407 'redirect_uri': self.redirect_uri,
408 'scope': self.scope
409 })
410 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500411 'user-agent': self.user_agent,
412 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500413 }
Joe Gregorioccc79542011-02-19 00:05:26 -0500414 if http is None:
415 http = httplib2.Http()
416 resp, content = http.request(self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500417 if resp.status == 200:
418 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
419 d = simplejson.loads(content)
420 access_token = d['access_token']
421 refresh_token = d.get('refresh_token', None)
422 token_expiry = None
423 if 'expires_in' in d:
424 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
425
426 logging.info('Successfully retrieved access token: %s' % content)
427 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500428 refresh_token, token_expiry, self.token_uri,
429 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500430 else:
431 logging.error('Failed to retrieve access token: %s' % content)
Joe Gregorioccc79542011-02-19 00:05:26 -0500432 error_msg = 'Invalid response %s.' % resp['status']
433 try:
434 d = simplejson.loads(content)
435 if 'error' in d:
436 error_msg = d['error']
437 except:
438 pass
439
440 raise FlowExchangeError(error_msg)