blob: 523a185b51e8b07877d0af66f6ac871eb88d8747 [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
JacobMoshenko8e905102011-06-20 09:53:10 -040086
Joe Gregorio695fdc12011-01-16 16:46:55 -050087class Flow(object):
88 """Base class for all Flow objects."""
89 pass
90
91
Joe Gregoriodeeb0202011-02-15 14:49:57 -050092class Storage(object):
93 """Base class for all Storage objects.
94
95 Store and retrieve a single credential.
96 """
97
Joe Gregoriodeeb0202011-02-15 14:49:57 -050098 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
JacobMoshenko8e905102011-06-20 09:53:10 -0400190 def _generate_refresh_request_body(self):
191 """Generate the body that will be used in the refresh request
192 """
193 body = urllib.urlencode({
194 'grant_type': 'refresh_token',
195 'client_id': self.client_id,
196 'client_secret': self.client_secret,
197 'refresh_token': self.refresh_token,
198 })
199 return body
200
201 def _generate_refresh_request_headers(self):
202 """Generate the headers that will be used in the refresh request
203 """
204 headers = {
205 'user-agent': self.user_agent,
206 'content-type': 'application/x-www-form-urlencoded',
207 }
208 return headers
209
Joe Gregorio695fdc12011-01-16 16:46:55 -0500210 def _refresh(self, http_request):
211 """Refresh the access_token using the refresh_token.
212
213 Args:
214 http: An instance of httplib2.Http.request
215 or something that acts like it.
216 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400217 body = self._generate_refresh_request_body()
218 headers = self._generate_refresh_request_headers()
219
Joe Gregorio205e73a2011-03-12 09:55:31 -0500220 logging.info("Refresing access_token")
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500221 resp, content = http_request(
222 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500223 if resp.status == 200:
224 # TODO(jcgregorio) Raise an error if loads fails?
225 d = simplejson.loads(content)
226 self.access_token = d['access_token']
227 self.refresh_token = d.get('refresh_token', self.refresh_token)
228 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500229 self.token_expiry = datetime.timedelta(
JacobMoshenko8e905102011-06-20 09:53:10 -0400230 seconds=int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500231 else:
232 self.token_expiry = None
233 if self.store is not None:
234 self.store(self)
235 else:
JacobMoshenko8e905102011-06-20 09:53:10 -0400236 # An {'error':...} response body means the token is expired or revoked,
237 # so we flag the credentials as such.
Joe Gregorioccc79542011-02-19 00:05:26 -0500238 logging.error('Failed to retrieve access token: %s' % content)
239 error_msg = 'Invalid response %s.' % resp['status']
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500240 try:
241 d = simplejson.loads(content)
242 if 'error' in d:
Joe Gregorioccc79542011-02-19 00:05:26 -0500243 error_msg = d['error']
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500244 self._invalid = True
Joe Gregorioa0a52e42011-02-17 17:13:26 -0500245 if self.store is not None:
246 self.store(self)
Joe Gregorio205e73a2011-03-12 09:55:31 -0500247 else:
JacobMoshenko8e905102011-06-20 09:53:10 -0400248 logging.warning(
249 "Unable to store refreshed credentials, no Storage provided.")
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500250 except:
251 pass
Joe Gregorioccc79542011-02-19 00:05:26 -0500252 raise AccessTokenRefreshError(error_msg)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500253
254 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500255 """Authorize an httplib2.Http instance with these credentials.
256
Joe Gregorio695fdc12011-01-16 16:46:55 -0500257 Args:
258 http: An instance of httplib2.Http
259 or something that acts like it.
260
261 Returns:
262 A modified instance of http that was passed in.
263
264 Example:
265
266 h = httplib2.Http()
267 h = credentials.authorize(h)
268
269 You can't create a new OAuth
270 subclass of httplib2.Authenication because
271 it never gets passed the absolute URI, which is
272 needed for signing. So instead we have to overload
273 'request' with a closure that adds in the
274 Authorization header and then calls the original version
275 of 'request()'.
276 """
277 request_orig = http.request
278
279 # The closure that will replace 'httplib2.Http.request'.
280 def new_request(uri, method='GET', body=None, headers=None,
281 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
282 connection_type=None):
JacobMoshenko8e905102011-06-20 09:53:10 -0400283 if not self.access_token:
284 logging.info("Attempting refresh to obtain initial access_token")
285 self._refresh(request_orig)
286
Joe Gregorio695fdc12011-01-16 16:46:55 -0500287 """Modify the request headers to add the appropriate
288 Authorization header."""
289 if headers == None:
290 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500291 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500292 if 'user-agent' in headers:
293 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
294 else:
295 headers['user-agent'] = self.user_agent
JacobMoshenko8e905102011-06-20 09:53:10 -0400296
Joe Gregorio695fdc12011-01-16 16:46:55 -0500297 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500298 redirections, connection_type)
JacobMoshenko8e905102011-06-20 09:53:10 -0400299
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500300 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500301 logging.info("Refreshing because we got a 401")
302 self._refresh(request_orig)
Joe Gregorioccc79542011-02-19 00:05:26 -0500303 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500304 return request_orig(uri, method, body, headers,
305 redirections, connection_type)
306 else:
307 return (resp, content)
308
309 http.request = new_request
310 return http
311
312
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500313class AccessTokenCredentials(OAuth2Credentials):
314 """Credentials object for OAuth 2.0
315
316 Credentials can be applied to an httplib2.Http object using the authorize()
317 method, which then signs each request from that object with the OAuth 2.0
318 access token. This set of credentials is for the use case where you have
319 acquired an OAuth 2.0 access_token from another place such as a JavaScript
320 client or another web application, and wish to use it from Python. Because
321 only the access_token is present it can not be refreshed and will in time
322 expire.
323
Joe Gregorio9ce4b622011-02-17 15:32:11 -0500324 AccessTokenCredentials objects may be safely pickled and unpickled.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500325
326 Usage:
327 credentials = AccessTokenCredentials('<an access token>',
328 'my-user-agent/1.0')
329 http = httplib2.Http()
330 http = credentials.authorize(http)
331
332 Exceptions:
333 AccessTokenCredentialsExpired: raised when the access_token expires or is
334 revoked.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500335 """
336
337 def __init__(self, access_token, user_agent):
338 """Create an instance of OAuth2Credentials
339
340 This is one of the few types if Credentials that you should contrust,
341 Credentials objects are usually instantiated by a Flow.
342
343 Args:
ade@google.com93a7f7c2011-02-23 16:00:37 +0000344 access_token: string, access token.
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500345 user_agent: string, The HTTP User-Agent to provide for this application.
346
347 Notes:
348 store: callable, a callable that when passed a Credential
349 will store the credential back to where it came from.
350 """
351 super(AccessTokenCredentials, self).__init__(
352 access_token,
353 None,
354 None,
355 None,
356 None,
357 None,
358 user_agent)
359
360 def _refresh(self, http_request):
361 raise AccessTokenCredentialsError(
362 "The access_token is expired or invalid and can't be refreshed.")
363
JacobMoshenko8e905102011-06-20 09:53:10 -0400364
365class AssertionCredentials(OAuth2Credentials):
366 """Abstract Credentials object used for OAuth 2.0 assertion grants
367
368 This credential does not require a flow to instantiate because it represents
369 a two legged flow, and therefore has all of the required information to
370 generate and refresh its own access tokens. It must be subclassed to
371 generate the appropriate assertion string.
372
373 AssertionCredentials objects may be safely pickled and unpickled.
374 """
375
376 def __init__(self, assertion_type, user_agent,
377 token_uri='https://accounts.google.com/o/oauth2/token', **kwargs):
378 """Constructor for AssertionFlowCredentials
379
380 Args:
381 assertion_type: string, assertion type that will be declared to the auth
382 server
383 user_agent: string, The HTTP User-Agent to provide for this application.
384 token_uri: string, URI for token endpoint. For convenience
385 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
386 """
387 super(AssertionCredentials, self).__init__(
388 None,
389 None,
390 None,
391 None,
392 None,
393 token_uri,
394 user_agent)
395 self.assertion_type = assertion_type
396
397 def _generate_refresh_request_body(self):
398 assertion = self._generate_assertion()
399
400 body = urllib.urlencode({
401 'assertion_type': self.assertion_type,
402 'assertion': assertion,
403 'grant_type': "assertion",
404 })
405
406 return body
407
408 def _generate_assertion(self):
409 """Generate the assertion string that will be used in the access token
410 request.
411 """
412 _abstract()
413
414
Joe Gregorio695fdc12011-01-16 16:46:55 -0500415class OAuth2WebServerFlow(Flow):
416 """Does the Web Server Flow for OAuth 2.0.
417
418 OAuth2Credentials objects may be safely pickled and unpickled.
419 """
420
421 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregoriob577f992011-03-10 08:35:11 -0500422 auth_uri='https://accounts.google.com/o/oauth2/auth',
423 token_uri='https://accounts.google.com/o/oauth2/token',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500424 **kwargs):
425 """Constructor for OAuth2WebServerFlow
426
427 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500428 client_id: string, client identifier.
429 client_secret: string client secret.
430 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500431 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500432 auth_uri: string, URI for authorization endpoint. For convenience
433 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
434 token_uri: string, URI for token endpoint. For convenience
435 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500436 **kwargs: dict, The keyword arguments are all optional and required
437 parameters for the OAuth calls.
438 """
439 self.client_id = client_id
440 self.client_secret = client_secret
441 self.scope = scope
442 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500443 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500444 self.token_uri = token_uri
445 self.params = kwargs
446 self.redirect_uri = None
447
448 def step1_get_authorize_url(self, redirect_uri='oob'):
449 """Returns a URI to redirect to the provider.
450
451 Args:
452 redirect_uri: string, Either the string 'oob' for a non-web-based
453 application, or a URI that handles the callback from
454 the authorization server.
455
456 If redirect_uri is 'oob' then pass in the
457 generated verification code to step2_exchange,
458 otherwise pass in the query parameters received
459 at the callback uri to step2_exchange.
460 """
461
462 self.redirect_uri = redirect_uri
463 query = {
464 'response_type': 'code',
465 'client_id': self.client_id,
466 'redirect_uri': redirect_uri,
467 'scope': self.scope,
468 }
469 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500470 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500471 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
472 parts[4] = urllib.urlencode(query)
473 return urlparse.urlunparse(parts)
474
Joe Gregorioccc79542011-02-19 00:05:26 -0500475 def step2_exchange(self, code, http=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500476 """Exhanges a code for OAuth2Credentials.
477
478 Args:
479 code: string or dict, either the code as a string, or a dictionary
480 of the query parameters to the redirect_uri, which contains
481 the code.
Joe Gregorioccc79542011-02-19 00:05:26 -0500482 http: httplib2.Http, optional http instance to use to do the fetch
Joe Gregorio695fdc12011-01-16 16:46:55 -0500483 """
484
485 if not (isinstance(code, str) or isinstance(code, unicode)):
486 code = code['code']
487
488 body = urllib.urlencode({
489 'grant_type': 'authorization_code',
490 'client_id': self.client_id,
491 'client_secret': self.client_secret,
492 'code': code,
493 'redirect_uri': self.redirect_uri,
JacobMoshenko8e905102011-06-20 09:53:10 -0400494 'scope': self.scope,
Joe Gregorio695fdc12011-01-16 16:46:55 -0500495 })
496 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500497 'user-agent': self.user_agent,
JacobMoshenko8e905102011-06-20 09:53:10 -0400498 'content-type': 'application/x-www-form-urlencoded',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500499 }
Joe Gregorioccc79542011-02-19 00:05:26 -0500500 if http is None:
501 http = httplib2.Http()
JacobMoshenko8e905102011-06-20 09:53:10 -0400502 resp, content = http.request(self.token_uri, method='POST', body=body,
503 headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500504 if resp.status == 200:
505 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
506 d = simplejson.loads(content)
507 access_token = d['access_token']
508 refresh_token = d.get('refresh_token', None)
509 token_expiry = None
510 if 'expires_in' in d:
JacobMoshenko8e905102011-06-20 09:53:10 -0400511 token_expiry = datetime.datetime.now() + datetime.timedelta(
512 seconds=int(d['expires_in']))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500513
514 logging.info('Successfully retrieved access token: %s' % content)
JacobMoshenko8e905102011-06-20 09:53:10 -0400515 return OAuth2Credentials(access_token, self.client_id,
516 self.client_secret, refresh_token, token_expiry,
517 self.token_uri, self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500518 else:
519 logging.error('Failed to retrieve access token: %s' % content)
Joe Gregorioccc79542011-02-19 00:05:26 -0500520 error_msg = 'Invalid response %s.' % resp['status']
521 try:
522 d = simplejson.loads(content)
523 if 'error' in d:
524 error_msg = d['error']
525 except:
526 pass
527
528 raise FlowExchangeError(error_msg)