blob: 6ef38a6cf68398818ea8422ba0262de8cdccb100 [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.
142 self.invalid = False
143
Joe Gregorio695fdc12011-01-16 16:46:55 -0500144 def set_store(self, store):
145 """Set the storage for the credential.
146
147 Args:
148 store: callable, a callable that when passed a Credential
149 will store the credential back to where it came from.
150 This is needed to store the latest access_token if it
151 has expired and been refreshed.
152 """
153 self.store = store
154
155 def __getstate__(self):
156 """Trim the state down to something that can be pickled.
157 """
158 d = copy.copy(self.__dict__)
159 del d['store']
160 return d
161
162 def __setstate__(self, state):
163 """Reconstitute the state of the object from being pickled.
164 """
165 self.__dict__.update(state)
166 self.store = None
167
168 def _refresh(self, http_request):
169 """Refresh the access_token using the refresh_token.
170
171 Args:
172 http: An instance of httplib2.Http.request
173 or something that acts like it.
174 """
175 body = urllib.urlencode({
176 'grant_type': 'refresh_token',
177 'client_id': self.client_id,
178 'client_secret': self.client_secret,
179 'refresh_token' : self.refresh_token
180 })
181 headers = {
182 'user-agent': self.user_agent,
183 'content-type': 'application/x-www-form-urlencoded'
184 }
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500185 resp, content = http_request(
186 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500187 if resp.status == 200:
188 # TODO(jcgregorio) Raise an error if loads fails?
189 d = simplejson.loads(content)
190 self.access_token = d['access_token']
191 self.refresh_token = d.get('refresh_token', self.refresh_token)
192 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500193 self.token_expiry = datetime.timedelta(
194 seconds = int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500195 else:
196 self.token_expiry = None
197 if self.store is not None:
198 self.store(self)
199 else:
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500200 # An {'error':...} response body means the token is expired or revoked, so
201 # we flag the credentials as such.
202 try:
203 d = simplejson.loads(content)
204 if 'error' in d:
205 self.invalid = True
206 self.store(self)
207 except:
208 pass
Joe Gregorio695fdc12011-01-16 16:46:55 -0500209 logging.error('Failed to retrieve access token: %s' % content)
210 raise RequestError('Invalid response %s.' % resp['status'])
211
212 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500213 """Authorize an httplib2.Http instance with these credentials.
214
Joe Gregorio695fdc12011-01-16 16:46:55 -0500215 Args:
216 http: An instance of httplib2.Http
217 or something that acts like it.
218
219 Returns:
220 A modified instance of http that was passed in.
221
222 Example:
223
224 h = httplib2.Http()
225 h = credentials.authorize(h)
226
227 You can't create a new OAuth
228 subclass of httplib2.Authenication because
229 it never gets passed the absolute URI, which is
230 needed for signing. So instead we have to overload
231 'request' with a closure that adds in the
232 Authorization header and then calls the original version
233 of 'request()'.
234 """
235 request_orig = http.request
236
237 # The closure that will replace 'httplib2.Http.request'.
238 def new_request(uri, method='GET', body=None, headers=None,
239 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
240 connection_type=None):
241 """Modify the request headers to add the appropriate
242 Authorization header."""
243 if headers == None:
244 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500245 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500246 if 'user-agent' in headers:
247 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
248 else:
249 headers['user-agent'] = self.user_agent
250 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500251 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500252 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500253 logging.info("Refreshing because we got a 401")
254 self._refresh(request_orig)
255 return request_orig(uri, method, body, headers,
256 redirections, connection_type)
257 else:
258 return (resp, content)
259
260 http.request = new_request
261 return http
262
263
Joe Gregorio3b79fa82011-02-17 11:47:17 -0500264class AccessTokenCredentials(OAuth2Credentials):
265 """Credentials object for OAuth 2.0
266
267 Credentials can be applied to an httplib2.Http object using the authorize()
268 method, which then signs each request from that object with the OAuth 2.0
269 access token. This set of credentials is for the use case where you have
270 acquired an OAuth 2.0 access_token from another place such as a JavaScript
271 client or another web application, and wish to use it from Python. Because
272 only the access_token is present it can not be refreshed and will in time
273 expire.
274
275 OAuth2Credentials objects may be safely pickled and unpickled.
276
277 Usage:
278 credentials = AccessTokenCredentials('<an access token>',
279 'my-user-agent/1.0')
280 http = httplib2.Http()
281 http = credentials.authorize(http)
282
283 Exceptions:
284 AccessTokenCredentialsExpired: raised when the access_token expires or is
285 revoked.
286
287 """
288
289 def __init__(self, access_token, user_agent):
290 """Create an instance of OAuth2Credentials
291
292 This is one of the few types if Credentials that you should contrust,
293 Credentials objects are usually instantiated by a Flow.
294
295 Args:
296 token_uri: string, URI of token endpoint.
297 user_agent: string, The HTTP User-Agent to provide for this application.
298
299 Notes:
300 store: callable, a callable that when passed a Credential
301 will store the credential back to where it came from.
302 """
303 super(AccessTokenCredentials, self).__init__(
304 access_token,
305 None,
306 None,
307 None,
308 None,
309 None,
310 user_agent)
311
312 def _refresh(self, http_request):
313 raise AccessTokenCredentialsError(
314 "The access_token is expired or invalid and can't be refreshed.")
315
Joe Gregorio695fdc12011-01-16 16:46:55 -0500316class OAuth2WebServerFlow(Flow):
317 """Does the Web Server Flow for OAuth 2.0.
318
319 OAuth2Credentials objects may be safely pickled and unpickled.
320 """
321
322 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500323 auth_uri='https://www.google.com/accounts/o8/oauth2/authorization',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500324 token_uri='https://www.google.com/accounts/o8/oauth2/token',
325 **kwargs):
326 """Constructor for OAuth2WebServerFlow
327
328 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500329 client_id: string, client identifier.
330 client_secret: string client secret.
331 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500332 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500333 auth_uri: string, URI for authorization endpoint. For convenience
334 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
335 token_uri: string, URI for token endpoint. For convenience
336 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500337 **kwargs: dict, The keyword arguments are all optional and required
338 parameters for the OAuth calls.
339 """
340 self.client_id = client_id
341 self.client_secret = client_secret
342 self.scope = scope
343 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500344 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500345 self.token_uri = token_uri
346 self.params = kwargs
347 self.redirect_uri = None
348
349 def step1_get_authorize_url(self, redirect_uri='oob'):
350 """Returns a URI to redirect to the provider.
351
352 Args:
353 redirect_uri: string, Either the string 'oob' for a non-web-based
354 application, or a URI that handles the callback from
355 the authorization server.
356
357 If redirect_uri is 'oob' then pass in the
358 generated verification code to step2_exchange,
359 otherwise pass in the query parameters received
360 at the callback uri to step2_exchange.
361 """
362
363 self.redirect_uri = redirect_uri
364 query = {
365 'response_type': 'code',
366 'client_id': self.client_id,
367 'redirect_uri': redirect_uri,
368 'scope': self.scope,
369 }
370 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500371 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500372 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
373 parts[4] = urllib.urlencode(query)
374 return urlparse.urlunparse(parts)
375
376 def step2_exchange(self, code):
377 """Exhanges a code for OAuth2Credentials.
378
379 Args:
380 code: string or dict, either the code as a string, or a dictionary
381 of the query parameters to the redirect_uri, which contains
382 the code.
383 """
384
385 if not (isinstance(code, str) or isinstance(code, unicode)):
386 code = code['code']
387
388 body = urllib.urlencode({
389 'grant_type': 'authorization_code',
390 'client_id': self.client_id,
391 'client_secret': self.client_secret,
392 'code': code,
393 'redirect_uri': self.redirect_uri,
394 'scope': self.scope
395 })
396 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500397 'user-agent': self.user_agent,
398 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500399 }
400 h = httplib2.Http()
401 resp, content = h.request(self.token_uri, method='POST', body=body, headers=headers)
402 if resp.status == 200:
403 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
404 d = simplejson.loads(content)
405 access_token = d['access_token']
406 refresh_token = d.get('refresh_token', None)
407 token_expiry = None
408 if 'expires_in' in d:
409 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
410
411 logging.info('Successfully retrieved access token: %s' % content)
412 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500413 refresh_token, token_expiry, self.token_uri,
414 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500415 else:
416 logging.error('Failed to retrieve access token: %s' % content)
417 raise RequestError('Invalid response %s.' % resp['status'])