blob: 3527a48e2f41bc4a598239a4294576efcad0b0c8 [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 Gregorio3b79fa82011-02-17 11:47:17 -0500211 self.store(self)
212 except:
213 pass
Joe Gregorio695fdc12011-01-16 16:46:55 -0500214 logging.error('Failed to retrieve access token: %s' % content)
215 raise RequestError('Invalid response %s.' % resp['status'])
216
217 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500218 """Authorize an httplib2.Http instance with these credentials.
219
Joe Gregorio695fdc12011-01-16 16:46:55 -0500220 Args:
221 http: An instance of httplib2.Http
222 or something that acts like it.
223
224 Returns:
225 A modified instance of http that was passed in.
226
227 Example:
228
229 h = httplib2.Http()
230 h = credentials.authorize(h)
231
232 You can't create a new OAuth
233 subclass of httplib2.Authenication because
234 it never gets passed the absolute URI, which is
235 needed for signing. So instead we have to overload
236 'request' with a closure that adds in the
237 Authorization header and then calls the original version
238 of 'request()'.
239 """
240 request_orig = http.request
241
242 # The closure that will replace 'httplib2.Http.request'.
243 def new_request(uri, method='GET', body=None, headers=None,
244 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
245 connection_type=None):
246 """Modify the request headers to add the appropriate
247 Authorization header."""
248 if headers == None:
249 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500250 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500251 if 'user-agent' in headers:
252 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
253 else:
254 headers['user-agent'] = self.user_agent
255 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500256 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500257 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500258 logging.info("Refreshing because we got a 401")
259 self._refresh(request_orig)
260 return request_orig(uri, method, body, headers,
261 redirections, connection_type)
262 else:
263 return (resp, content)
264
265 http.request = new_request
266 return http
267
268
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500269class AccessTokenCredentials(OAuth2Credentials):
270 """Credentials object for OAuth 2.0
271
272 Credentials can be applied to an httplib2.Http object using the authorize()
273 method, which then signs each request from that object with the OAuth 2.0
274 access token. This set of credentials is for the use case where you have
275 acquired an OAuth 2.0 access_token from another place such as a JavaScript
276 client or another web application, and wish to use it from Python. Because
277 only the access_token is present it can not be refreshed and will in time
278 expire.
279
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500280 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500281
282 Usage:
283 credentials = AccessTokenCredentials('<an access token>',
284 'my-user-agent/1.0')
285 http = httplib2.Http()
286 http = credentials.authorize(http)
287
288 Exceptions:
289 AccessTokenCredentialsExpired: raised when the access_token expires or is
290 revoked.
291
292 """
293
294 def __init__(self, access_token, user_agent):
295 """Create an instance of OAuth2Credentials
296
297 This is one of the few types if Credentials that you should contrust,
298 Credentials objects are usually instantiated by a Flow.
299
300 Args:
301 token_uri: string, URI of token endpoint.
302 user_agent: string, The HTTP User-Agent to provide for this application.
303
304 Notes:
305 store: callable, a callable that when passed a Credential
306 will store the credential back to where it came from.
307 """
308 super(AccessTokenCredentials, self).__init__(
309 access_token,
310 None,
311 None,
312 None,
313 None,
314 None,
315 user_agent)
316
317 def _refresh(self, http_request):
318 raise AccessTokenCredentialsError(
319 "The access_token is expired or invalid and can't be refreshed.")
320
Joe Gregorio695fdc12011-01-16 16:46:55 -0500321class OAuth2WebServerFlow(Flow):
322 """Does the Web Server Flow for OAuth 2.0.
323
324 OAuth2Credentials objects may be safely pickled and unpickled.
325 """
326
327 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500328 auth_uri='https://www.google.com/accounts/o8/oauth2/authorization',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500329 token_uri='https://www.google.com/accounts/o8/oauth2/token',
330 **kwargs):
331 """Constructor for OAuth2WebServerFlow
332
333 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500334 client_id: string, client identifier.
335 client_secret: string client secret.
336 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500337 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500338 auth_uri: string, URI for authorization endpoint. For convenience
339 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
340 token_uri: string, URI for token endpoint. For convenience
341 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500342 **kwargs: dict, The keyword arguments are all optional and required
343 parameters for the OAuth calls.
344 """
345 self.client_id = client_id
346 self.client_secret = client_secret
347 self.scope = scope
348 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500349 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500350 self.token_uri = token_uri
351 self.params = kwargs
352 self.redirect_uri = None
353
354 def step1_get_authorize_url(self, redirect_uri='oob'):
355 """Returns a URI to redirect to the provider.
356
357 Args:
358 redirect_uri: string, Either the string 'oob' for a non-web-based
359 application, or a URI that handles the callback from
360 the authorization server.
361
362 If redirect_uri is 'oob' then pass in the
363 generated verification code to step2_exchange,
364 otherwise pass in the query parameters received
365 at the callback uri to step2_exchange.
366 """
367
368 self.redirect_uri = redirect_uri
369 query = {
370 'response_type': 'code',
371 'client_id': self.client_id,
372 'redirect_uri': redirect_uri,
373 'scope': self.scope,
374 }
375 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500376 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500377 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
378 parts[4] = urllib.urlencode(query)
379 return urlparse.urlunparse(parts)
380
381 def step2_exchange(self, code):
382 """Exhanges a code for OAuth2Credentials.
383
384 Args:
385 code: string or dict, either the code as a string, or a dictionary
386 of the query parameters to the redirect_uri, which contains
387 the code.
388 """
389
390 if not (isinstance(code, str) or isinstance(code, unicode)):
391 code = code['code']
392
393 body = urllib.urlencode({
394 'grant_type': 'authorization_code',
395 'client_id': self.client_id,
396 'client_secret': self.client_secret,
397 'code': code,
398 'redirect_uri': self.redirect_uri,
399 'scope': self.scope
400 })
401 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500402 'user-agent': self.user_agent,
403 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500404 }
405 h = httplib2.Http()
406 resp, content = h.request(self.token_uri, method='POST', body=body, headers=headers)
407 if resp.status == 200:
408 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
409 d = simplejson.loads(content)
410 access_token = d['access_token']
411 refresh_token = d.get('refresh_token', None)
412 token_expiry = None
413 if 'expires_in' in d:
414 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
415
416 logging.info('Successfully retrieved access token: %s' % content)
417 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500418 refresh_token, token_expiry, self.token_uri,
419 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500420 else:
421 logging.error('Failed to retrieve access token: %s' % content)
422 raise RequestError('Invalid response %s.' % resp['status'])