blob: e972564a7e9a2391b8f108f00f4c1d90c63d9fca [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
39class RequestError(Error):
40 """Error occurred during request."""
41 pass
42
43
Joe Gregorio3b79fa82011-02-17 11:47:17 -050044class AccessTokenCredentialsError(Error):
45 """Having only the access_token means no refresh is possible."""
Joe Gregorio695fdc12011-01-16 16:46:55 -050046 pass
47
48
49def _abstract():
50 raise NotImplementedError('You need to override this function')
51
52
53class Credentials(object):
54 """Base class for all Credentials objects.
55
56 Subclasses must define an authorize() method
57 that applies the credentials to an HTTP transport.
58 """
59
60 def authorize(self, http):
61 """Take an httplib2.Http instance (or equivalent) and
62 authorizes it for the set of credentials, usually by
63 replacing http.request() with a method that adds in
64 the appropriate headers and then delegates to the original
65 Http.request() method.
66 """
67 _abstract()
68
69class Flow(object):
70 """Base class for all Flow objects."""
71 pass
72
73
Joe Gregoriodeeb0202011-02-15 14:49:57 -050074class Storage(object):
75 """Base class for all Storage objects.
76
77 Store and retrieve a single credential.
78 """
79
80
81 def get(self):
82 """Retrieve credential.
83
84 Returns:
85 apiclient.oauth.Credentials
86 """
87 _abstract()
88
89 def put(self, credentials):
90 """Write a credential.
91
92 Args:
93 credentials: Credentials, the credentials to store.
94 """
95 _abstract()
96
97
Joe Gregorio695fdc12011-01-16 16:46:55 -050098class OAuth2Credentials(Credentials):
99 """Credentials object for OAuth 2.0
100
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500101 Credentials can be applied to an httplib2.Http object using the authorize()
102 method, which then signs each request from that object with the OAuth 2.0
103 access token.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500104
105 OAuth2Credentials objects may be safely pickled and unpickled.
106 """
107
108 def __init__(self, access_token, client_id, client_secret, refresh_token,
109 token_expiry, token_uri, user_agent):
110 """Create an instance of OAuth2Credentials
111
112 This constructor is not usually called by the user, instead
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500113 OAuth2Credentials objects are instantiated by the OAuth2WebServerFlow.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500114
115 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500116 token_uri: string, URI of token endpoint.
117 client_id: string, client identifier.
118 client_secret: string, client secret.
119 access_token: string, access token.
120 token_expiry: datetime, when the access_token expires.
121 refresh_token: string, refresh token.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500122 user_agent: string, The HTTP User-Agent to provide for this application.
123
124
125 Notes:
126 store: callable, a callable that when passed a Credential
127 will store the credential back to where it came from.
128 This is needed to store the latest access_token if it
129 has expired and been refreshed.
130 """
131 self.access_token = access_token
132 self.client_id = client_id
133 self.client_secret = client_secret
134 self.refresh_token = refresh_token
135 self.store = None
136 self.token_expiry = token_expiry
137 self.token_uri = token_uri
138 self.user_agent = user_agent
139
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500140 # True if the credentials have been revoked or expired and can't be
141 # refreshed.
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500142 self._invalid = False
143
144 @property
145 def invalid(self):
146 """True if the credentials are invalid, such as being revoked."""
147 return self._invalid
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500148
Joe Gregorio695fdc12011-01-16 16:46:55 -0500149 def set_store(self, store):
150 """Set the storage for the credential.
151
152 Args:
153 store: callable, a callable that when passed a Credential
154 will store the credential back to where it came from.
155 This is needed to store the latest access_token if it
156 has expired and been refreshed.
157 """
158 self.store = store
159
160 def __getstate__(self):
161 """Trim the state down to something that can be pickled.
162 """
163 d = copy.copy(self.__dict__)
164 del d['store']
165 return d
166
167 def __setstate__(self, state):
168 """Reconstitute the state of the object from being pickled.
169 """
170 self.__dict__.update(state)
171 self.store = None
172
173 def _refresh(self, http_request):
174 """Refresh the access_token using the refresh_token.
175
176 Args:
177 http: An instance of httplib2.Http.request
178 or something that acts like it.
179 """
180 body = urllib.urlencode({
181 'grant_type': 'refresh_token',
182 'client_id': self.client_id,
183 'client_secret': self.client_secret,
184 'refresh_token' : self.refresh_token
185 })
186 headers = {
187 'user-agent': self.user_agent,
188 'content-type': 'application/x-www-form-urlencoded'
189 }
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500190 resp, content = http_request(
191 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500192 if resp.status == 200:
193 # TODO(jcgregorio) Raise an error if loads fails?
194 d = simplejson.loads(content)
195 self.access_token = d['access_token']
196 self.refresh_token = d.get('refresh_token', self.refresh_token)
197 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500198 self.token_expiry = datetime.timedelta(
199 seconds = int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500200 else:
201 self.token_expiry = None
202 if self.store is not None:
203 self.store(self)
204 else:
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500205 # An {'error':...} response body means the token is expired or revoked, so
206 # we flag the credentials as such.
207 try:
208 d = simplejson.loads(content)
209 if 'error' in d:
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500210 self._invalid = True
Joe Gregorioa0a52e42011-02-17 17:13:26 -0500211 if self.store is not None:
212 self.store(self)
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500213 except:
214 pass
Joe Gregorio695fdc12011-01-16 16:46:55 -0500215 logging.error('Failed to retrieve access token: %s' % content)
216 raise RequestError('Invalid response %s.' % resp['status'])
217
218 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500219 """Authorize an httplib2.Http instance with these credentials.
220
Joe Gregorio695fdc12011-01-16 16:46:55 -0500221 Args:
222 http: An instance of httplib2.Http
223 or something that acts like it.
224
225 Returns:
226 A modified instance of http that was passed in.
227
228 Example:
229
230 h = httplib2.Http()
231 h = credentials.authorize(h)
232
233 You can't create a new OAuth
234 subclass of httplib2.Authenication because
235 it never gets passed the absolute URI, which is
236 needed for signing. So instead we have to overload
237 'request' with a closure that adds in the
238 Authorization header and then calls the original version
239 of 'request()'.
240 """
241 request_orig = http.request
242
243 # The closure that will replace 'httplib2.Http.request'.
244 def new_request(uri, method='GET', body=None, headers=None,
245 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
246 connection_type=None):
247 """Modify the request headers to add the appropriate
248 Authorization header."""
249 if headers == None:
250 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500251 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500252 if 'user-agent' in headers:
253 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
254 else:
255 headers['user-agent'] = self.user_agent
256 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500257 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500258 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500259 logging.info("Refreshing because we got a 401")
260 self._refresh(request_orig)
261 return request_orig(uri, method, body, headers,
262 redirections, connection_type)
263 else:
264 return (resp, content)
265
266 http.request = new_request
267 return http
268
269
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500270class AccessTokenCredentials(OAuth2Credentials):
271 """Credentials object for OAuth 2.0
272
273 Credentials can be applied to an httplib2.Http object using the authorize()
274 method, which then signs each request from that object with the OAuth 2.0
275 access token. This set of credentials is for the use case where you have
276 acquired an OAuth 2.0 access_token from another place such as a JavaScript
277 client or another web application, and wish to use it from Python. Because
278 only the access_token is present it can not be refreshed and will in time
279 expire.
280
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500281 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500282
283 Usage:
284 credentials = AccessTokenCredentials('<an access token>',
285 'my-user-agent/1.0')
286 http = httplib2.Http()
287 http = credentials.authorize(http)
288
289 Exceptions:
290 AccessTokenCredentialsExpired: raised when the access_token expires or is
291 revoked.
292
293 """
294
295 def __init__(self, access_token, user_agent):
296 """Create an instance of OAuth2Credentials
297
298 This is one of the few types if Credentials that you should contrust,
299 Credentials objects are usually instantiated by a Flow.
300
301 Args:
302 token_uri: string, URI of token endpoint.
303 user_agent: string, The HTTP User-Agent to provide for this application.
304
305 Notes:
306 store: callable, a callable that when passed a Credential
307 will store the credential back to where it came from.
308 """
309 super(AccessTokenCredentials, self).__init__(
310 access_token,
311 None,
312 None,
313 None,
314 None,
315 None,
316 user_agent)
317
318 def _refresh(self, http_request):
319 raise AccessTokenCredentialsError(
320 "The access_token is expired or invalid and can't be refreshed.")
321
Joe Gregorio695fdc12011-01-16 16:46:55 -0500322class OAuth2WebServerFlow(Flow):
323 """Does the Web Server Flow for OAuth 2.0.
324
325 OAuth2Credentials objects may be safely pickled and unpickled.
326 """
327
328 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500329 auth_uri='https://www.google.com/accounts/o8/oauth2/authorization',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500330 token_uri='https://www.google.com/accounts/o8/oauth2/token',
331 **kwargs):
332 """Constructor for OAuth2WebServerFlow
333
334 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500335 client_id: string, client identifier.
336 client_secret: string client secret.
337 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500338 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500339 auth_uri: string, URI for authorization endpoint. For convenience
340 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
341 token_uri: string, URI for token endpoint. For convenience
342 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500343 **kwargs: dict, The keyword arguments are all optional and required
344 parameters for the OAuth calls.
345 """
346 self.client_id = client_id
347 self.client_secret = client_secret
348 self.scope = scope
349 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500350 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500351 self.token_uri = token_uri
352 self.params = kwargs
353 self.redirect_uri = None
354
355 def step1_get_authorize_url(self, redirect_uri='oob'):
356 """Returns a URI to redirect to the provider.
357
358 Args:
359 redirect_uri: string, Either the string 'oob' for a non-web-based
360 application, or a URI that handles the callback from
361 the authorization server.
362
363 If redirect_uri is 'oob' then pass in the
364 generated verification code to step2_exchange,
365 otherwise pass in the query parameters received
366 at the callback uri to step2_exchange.
367 """
368
369 self.redirect_uri = redirect_uri
370 query = {
371 'response_type': 'code',
372 'client_id': self.client_id,
373 'redirect_uri': redirect_uri,
374 'scope': self.scope,
375 }
376 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500377 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500378 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
379 parts[4] = urllib.urlencode(query)
380 return urlparse.urlunparse(parts)
381
382 def step2_exchange(self, code):
383 """Exhanges a code for OAuth2Credentials.
384
385 Args:
386 code: string or dict, either the code as a string, or a dictionary
387 of the query parameters to the redirect_uri, which contains
388 the code.
389 """
390
391 if not (isinstance(code, str) or isinstance(code, unicode)):
392 code = code['code']
393
394 body = urllib.urlencode({
395 'grant_type': 'authorization_code',
396 'client_id': self.client_id,
397 'client_secret': self.client_secret,
398 'code': code,
399 'redirect_uri': self.redirect_uri,
400 'scope': self.scope
401 })
402 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500403 'user-agent': self.user_agent,
404 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500405 }
406 h = httplib2.Http()
407 resp, content = h.request(self.token_uri, method='POST', body=body, headers=headers)
408 if resp.status == 200:
409 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
410 d = simplejson.loads(content)
411 access_token = d['access_token']
412 refresh_token = d.get('refresh_token', None)
413 token_expiry = None
414 if 'expires_in' in d:
415 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
416
417 logging.info('Successfully retrieved access token: %s' % content)
418 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500419 refresh_token, token_expiry, self.token_uri,
420 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500421 else:
422 logging.error('Failed to retrieve access token: %s' % content)
423 raise RequestError('Invalid response %s.' % resp['status'])