blob: 3c5998058b7686901a90bddf1a059141826a3f8d [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
86class Flow(object):
87 """Base class for all Flow objects."""
88 pass
89
90
Joe Gregoriodeeb0202011-02-15 14:49:57 -050091class Storage(object):
92 """Base class for all Storage objects.
93
94 Store and retrieve a single credential.
95 """
96
97
98 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
190 def _refresh(self, http_request):
191 """Refresh the access_token using the refresh_token.
192
193 Args:
194 http: An instance of httplib2.Http.request
195 or something that acts like it.
196 """
197 body = urllib.urlencode({
198 'grant_type': 'refresh_token',
199 'client_id': self.client_id,
200 'client_secret': self.client_secret,
201 'refresh_token' : self.refresh_token
202 })
203 headers = {
204 'user-agent': self.user_agent,
205 'content-type': 'application/x-www-form-urlencoded'
206 }
Joe Gregorio205e73a2011-03-12 09:55:31 -0500207 logging.info("Refresing access_token")
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500208 resp, content = http_request(
209 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500210 if resp.status == 200:
211 # TODO(jcgregorio) Raise an error if loads fails?
212 d = simplejson.loads(content)
213 self.access_token = d['access_token']
214 self.refresh_token = d.get('refresh_token', self.refresh_token)
215 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500216 self.token_expiry = datetime.timedelta(
217 seconds = int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500218 else:
219 self.token_expiry = None
220 if self.store is not None:
221 self.store(self)
222 else:
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500223 # An {'error':...} response body means the token is expired or revoked, so
224 # we flag the credentials as such.
Joe Gregorioccc79542011-02-19 00:05:26 -0500225 logging.error('Failed to retrieve access token: %s' % content)
226 error_msg = 'Invalid response %s.' % resp['status']
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500227 try:
228 d = simplejson.loads(content)
229 if 'error' in d:
Joe Gregorioccc79542011-02-19 00:05:26 -0500230 error_msg = d['error']
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500231 self._invalid = True
Joe Gregorioa0a52e42011-02-17 17:13:26 -0500232 if self.store is not None:
233 self.store(self)
Joe Gregorio205e73a2011-03-12 09:55:31 -0500234 else:
235 logging.warning("Unable to store refreshed credentials, no Storage provided.")
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500236 except:
237 pass
Joe Gregorioccc79542011-02-19 00:05:26 -0500238 raise AccessTokenRefreshError(error_msg)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500239
240 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500241 """Authorize an httplib2.Http instance with these credentials.
242
Joe Gregorio695fdc12011-01-16 16:46:55 -0500243 Args:
244 http: An instance of httplib2.Http
245 or something that acts like it.
246
247 Returns:
248 A modified instance of http that was passed in.
249
250 Example:
251
252 h = httplib2.Http()
253 h = credentials.authorize(h)
254
255 You can't create a new OAuth
256 subclass of httplib2.Authenication because
257 it never gets passed the absolute URI, which is
258 needed for signing. So instead we have to overload
259 'request' with a closure that adds in the
260 Authorization header and then calls the original version
261 of 'request()'.
262 """
263 request_orig = http.request
264
265 # The closure that will replace 'httplib2.Http.request'.
266 def new_request(uri, method='GET', body=None, headers=None,
267 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
268 connection_type=None):
269 """Modify the request headers to add the appropriate
270 Authorization header."""
271 if headers == None:
272 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500273 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500274 if 'user-agent' in headers:
275 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
276 else:
277 headers['user-agent'] = self.user_agent
278 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500279 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500280 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500281 logging.info("Refreshing because we got a 401")
282 self._refresh(request_orig)
Joe Gregorioccc79542011-02-19 00:05:26 -0500283 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500284 return request_orig(uri, method, body, headers,
285 redirections, connection_type)
286 else:
287 return (resp, content)
288
289 http.request = new_request
290 return http
291
292
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500293class AccessTokenCredentials(OAuth2Credentials):
294 """Credentials object for OAuth 2.0
295
296 Credentials can be applied to an httplib2.Http object using the authorize()
297 method, which then signs each request from that object with the OAuth 2.0
298 access token. This set of credentials is for the use case where you have
299 acquired an OAuth 2.0 access_token from another place such as a JavaScript
300 client or another web application, and wish to use it from Python. Because
301 only the access_token is present it can not be refreshed and will in time
302 expire.
303
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500304 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500305
306 Usage:
307 credentials = AccessTokenCredentials('<an access token>',
308 'my-user-agent/1.0')
309 http = httplib2.Http()
310 http = credentials.authorize(http)
311
312 Exceptions:
313 AccessTokenCredentialsExpired: raised when the access_token expires or is
314 revoked.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500315 """
316
317 def __init__(self, access_token, user_agent):
318 """Create an instance of OAuth2Credentials
319
320 This is one of the few types if Credentials that you should contrust,
321 Credentials objects are usually instantiated by a Flow.
322
323 Args:
ade@google.com93a7f7c2011-02-23 16:00:37 +0000324 access_token: string, access token.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500325 user_agent: string, The HTTP User-Agent to provide for this application.
326
327 Notes:
328 store: callable, a callable that when passed a Credential
329 will store the credential back to where it came from.
330 """
331 super(AccessTokenCredentials, self).__init__(
332 access_token,
333 None,
334 None,
335 None,
336 None,
337 None,
338 user_agent)
339
340 def _refresh(self, http_request):
341 raise AccessTokenCredentialsError(
342 "The access_token is expired or invalid and can't be refreshed.")
343
Joe Gregorio695fdc12011-01-16 16:46:55 -0500344class OAuth2WebServerFlow(Flow):
345 """Does the Web Server Flow for OAuth 2.0.
346
347 OAuth2Credentials objects may be safely pickled and unpickled.
348 """
349
350 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregoriob577f992011-03-10 08:35:11 -0500351 auth_uri='https://accounts.google.com/o/oauth2/auth',
352 token_uri='https://accounts.google.com/o/oauth2/token',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500353 **kwargs):
354 """Constructor for OAuth2WebServerFlow
355
356 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500357 client_id: string, client identifier.
358 client_secret: string client secret.
359 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500360 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500361 auth_uri: string, URI for authorization endpoint. For convenience
362 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
363 token_uri: string, URI for token endpoint. For convenience
364 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500365 **kwargs: dict, The keyword arguments are all optional and required
366 parameters for the OAuth calls.
367 """
368 self.client_id = client_id
369 self.client_secret = client_secret
370 self.scope = scope
371 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500372 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500373 self.token_uri = token_uri
374 self.params = kwargs
375 self.redirect_uri = None
376
377 def step1_get_authorize_url(self, redirect_uri='oob'):
378 """Returns a URI to redirect to the provider.
379
380 Args:
381 redirect_uri: string, Either the string 'oob' for a non-web-based
382 application, or a URI that handles the callback from
383 the authorization server.
384
385 If redirect_uri is 'oob' then pass in the
386 generated verification code to step2_exchange,
387 otherwise pass in the query parameters received
388 at the callback uri to step2_exchange.
389 """
390
391 self.redirect_uri = redirect_uri
392 query = {
393 'response_type': 'code',
394 'client_id': self.client_id,
395 'redirect_uri': redirect_uri,
396 'scope': self.scope,
397 }
398 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500399 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500400 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
401 parts[4] = urllib.urlencode(query)
402 return urlparse.urlunparse(parts)
403
Joe Gregorioccc79542011-02-19 00:05:26 -0500404 def step2_exchange(self, code, http=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500405 """Exhanges a code for OAuth2Credentials.
406
407 Args:
408 code: string or dict, either the code as a string, or a dictionary
409 of the query parameters to the redirect_uri, which contains
410 the code.
Joe Gregorioccc79542011-02-19 00:05:26 -0500411 http: httplib2.Http, optional http instance to use to do the fetch
Joe Gregorio695fdc12011-01-16 16:46:55 -0500412 """
413
414 if not (isinstance(code, str) or isinstance(code, unicode)):
415 code = code['code']
416
417 body = urllib.urlencode({
418 'grant_type': 'authorization_code',
419 'client_id': self.client_id,
420 'client_secret': self.client_secret,
421 'code': code,
422 'redirect_uri': self.redirect_uri,
423 'scope': self.scope
424 })
425 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500426 'user-agent': self.user_agent,
427 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500428 }
Joe Gregorioccc79542011-02-19 00:05:26 -0500429 if http is None:
430 http = httplib2.Http()
431 resp, content = http.request(self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500432 if resp.status == 200:
433 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
434 d = simplejson.loads(content)
435 access_token = d['access_token']
436 refresh_token = d.get('refresh_token', None)
437 token_expiry = None
438 if 'expires_in' in d:
439 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
440
441 logging.info('Successfully retrieved access token: %s' % content)
442 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500443 refresh_token, token_expiry, self.token_uri,
444 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500445 else:
446 logging.error('Failed to retrieve access token: %s' % content)
Joe Gregorioccc79542011-02-19 00:05:26 -0500447 error_msg = 'Invalid response %s.' % resp['status']
448 try:
449 d = simplejson.loads(content)
450 if 'error' in d:
451 error_msg = d['error']
452 except:
453 pass
454
455 raise FlowExchangeError(error_msg)