blob: d05b9466e8b45cedfd53bf7afa0dce76e980f784 [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
44class MissingParameter(Error):
45 pass
46
47
48def _abstract():
49 raise NotImplementedError('You need to override this function')
50
51
52class Credentials(object):
53 """Base class for all Credentials objects.
54
55 Subclasses must define an authorize() method
56 that applies the credentials to an HTTP transport.
57 """
58
59 def authorize(self, http):
60 """Take an httplib2.Http instance (or equivalent) and
61 authorizes it for the set of credentials, usually by
62 replacing http.request() with a method that adds in
63 the appropriate headers and then delegates to the original
64 Http.request() method.
65 """
66 _abstract()
67
68class Flow(object):
69 """Base class for all Flow objects."""
70 pass
71
72
Joe Gregoriodeeb0202011-02-15 14:49:57 -050073class Storage(object):
74 """Base class for all Storage objects.
75
76 Store and retrieve a single credential.
77 """
78
79
80 def get(self):
81 """Retrieve credential.
82
83 Returns:
84 apiclient.oauth.Credentials
85 """
86 _abstract()
87
88 def put(self, credentials):
89 """Write a credential.
90
91 Args:
92 credentials: Credentials, the credentials to store.
93 """
94 _abstract()
95
96
Joe Gregorio695fdc12011-01-16 16:46:55 -050097class OAuth2Credentials(Credentials):
98 """Credentials object for OAuth 2.0
99
100 Credentials can be applied to an httplib2.Http object
101 using the authorize() method, which then signs each
102 request from that object with the OAuth 2.0 access token.
103
104 OAuth2Credentials objects may be safely pickled and unpickled.
105 """
106
107 def __init__(self, access_token, client_id, client_secret, refresh_token,
108 token_expiry, token_uri, user_agent):
109 """Create an instance of OAuth2Credentials
110
111 This constructor is not usually called by the user, instead
112 OAuth2Credentials objects are instantiated by
113 the OAuth2WebServerFlow.
114
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
140 def set_store(self, store):
141 """Set the storage for the credential.
142
143 Args:
144 store: callable, a callable that when passed a Credential
145 will store the credential back to where it came from.
146 This is needed to store the latest access_token if it
147 has expired and been refreshed.
148 """
149 self.store = store
150
151 def __getstate__(self):
152 """Trim the state down to something that can be pickled.
153 """
154 d = copy.copy(self.__dict__)
155 del d['store']
156 return d
157
158 def __setstate__(self, state):
159 """Reconstitute the state of the object from being pickled.
160 """
161 self.__dict__.update(state)
162 self.store = None
163
164 def _refresh(self, http_request):
165 """Refresh the access_token using the refresh_token.
166
167 Args:
168 http: An instance of httplib2.Http.request
169 or something that acts like it.
170 """
171 body = urllib.urlencode({
172 'grant_type': 'refresh_token',
173 'client_id': self.client_id,
174 'client_secret': self.client_secret,
175 'refresh_token' : self.refresh_token
176 })
177 headers = {
178 'user-agent': self.user_agent,
179 'content-type': 'application/x-www-form-urlencoded'
180 }
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500181 resp, content = http_request(
182 self.token_uri, method='POST', body=body, headers=headers)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500183 if resp.status == 200:
184 # TODO(jcgregorio) Raise an error if loads fails?
185 d = simplejson.loads(content)
186 self.access_token = d['access_token']
187 self.refresh_token = d.get('refresh_token', self.refresh_token)
188 if 'expires_in' in d:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500189 self.token_expiry = datetime.timedelta(
190 seconds = int(d['expires_in'])) + datetime.datetime.now()
Joe Gregorio695fdc12011-01-16 16:46:55 -0500191 else:
192 self.token_expiry = None
193 if self.store is not None:
194 self.store(self)
195 else:
196 logging.error('Failed to retrieve access token: %s' % content)
197 raise RequestError('Invalid response %s.' % resp['status'])
198
199 def authorize(self, http):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500200 """Authorize an httplib2.Http instance with these credentials.
201
Joe Gregorio695fdc12011-01-16 16:46:55 -0500202 Args:
203 http: An instance of httplib2.Http
204 or something that acts like it.
205
206 Returns:
207 A modified instance of http that was passed in.
208
209 Example:
210
211 h = httplib2.Http()
212 h = credentials.authorize(h)
213
214 You can't create a new OAuth
215 subclass of httplib2.Authenication because
216 it never gets passed the absolute URI, which is
217 needed for signing. So instead we have to overload
218 'request' with a closure that adds in the
219 Authorization header and then calls the original version
220 of 'request()'.
221 """
222 request_orig = http.request
223
224 # The closure that will replace 'httplib2.Http.request'.
225 def new_request(uri, method='GET', body=None, headers=None,
226 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
227 connection_type=None):
228 """Modify the request headers to add the appropriate
229 Authorization header."""
230 if headers == None:
231 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500232 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500233 if 'user-agent' in headers:
234 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
235 else:
236 headers['user-agent'] = self.user_agent
237 resp, content = request_orig(uri, method, body, headers,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500238 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500239 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500240 logging.info("Refreshing because we got a 401")
241 self._refresh(request_orig)
242 return request_orig(uri, method, body, headers,
243 redirections, connection_type)
244 else:
245 return (resp, content)
246
247 http.request = new_request
248 return http
249
250
251class OAuth2WebServerFlow(Flow):
252 """Does the Web Server Flow for OAuth 2.0.
253
254 OAuth2Credentials objects may be safely pickled and unpickled.
255 """
256
257 def __init__(self, client_id, client_secret, scope, user_agent,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500258 auth_uri='https://www.google.com/accounts/o8/oauth2/authorization',
Joe Gregorio695fdc12011-01-16 16:46:55 -0500259 token_uri='https://www.google.com/accounts/o8/oauth2/token',
260 **kwargs):
261 """Constructor for OAuth2WebServerFlow
262
263 Args:
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500264 client_id: string, client identifier.
265 client_secret: string client secret.
266 scope: string, scope of the credentials being requested.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500267 user_agent: string, HTTP User-Agent to provide for this application.
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500268 auth_uri: string, URI for authorization endpoint. For convenience
269 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
270 token_uri: string, URI for token endpoint. For convenience
271 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregorio695fdc12011-01-16 16:46:55 -0500272 **kwargs: dict, The keyword arguments are all optional and required
273 parameters for the OAuth calls.
274 """
275 self.client_id = client_id
276 self.client_secret = client_secret
277 self.scope = scope
278 self.user_agent = user_agent
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500279 self.auth_uri = auth_uri
Joe Gregorio695fdc12011-01-16 16:46:55 -0500280 self.token_uri = token_uri
281 self.params = kwargs
282 self.redirect_uri = None
283
284 def step1_get_authorize_url(self, redirect_uri='oob'):
285 """Returns a URI to redirect to the provider.
286
287 Args:
288 redirect_uri: string, Either the string 'oob' for a non-web-based
289 application, or a URI that handles the callback from
290 the authorization server.
291
292 If redirect_uri is 'oob' then pass in the
293 generated verification code to step2_exchange,
294 otherwise pass in the query parameters received
295 at the callback uri to step2_exchange.
296 """
297
298 self.redirect_uri = redirect_uri
299 query = {
300 'response_type': 'code',
301 'client_id': self.client_id,
302 'redirect_uri': redirect_uri,
303 'scope': self.scope,
304 }
305 query.update(self.params)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500306 parts = list(urlparse.urlparse(self.auth_uri))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500307 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
308 parts[4] = urllib.urlencode(query)
309 return urlparse.urlunparse(parts)
310
311 def step2_exchange(self, code):
312 """Exhanges a code for OAuth2Credentials.
313
314 Args:
315 code: string or dict, either the code as a string, or a dictionary
316 of the query parameters to the redirect_uri, which contains
317 the code.
318 """
319
320 if not (isinstance(code, str) or isinstance(code, unicode)):
321 code = code['code']
322
323 body = urllib.urlencode({
324 'grant_type': 'authorization_code',
325 'client_id': self.client_id,
326 'client_secret': self.client_secret,
327 'code': code,
328 'redirect_uri': self.redirect_uri,
329 'scope': self.scope
330 })
331 headers = {
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500332 'user-agent': self.user_agent,
333 'content-type': 'application/x-www-form-urlencoded'
Joe Gregorio695fdc12011-01-16 16:46:55 -0500334 }
335 h = httplib2.Http()
336 resp, content = h.request(self.token_uri, method='POST', body=body, headers=headers)
337 if resp.status == 200:
338 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
339 d = simplejson.loads(content)
340 access_token = d['access_token']
341 refresh_token = d.get('refresh_token', None)
342 token_expiry = None
343 if 'expires_in' in d:
344 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
345
346 logging.info('Successfully retrieved access token: %s' % content)
347 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500348 refresh_token, token_expiry, self.token_uri,
349 self.user_agent)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500350 else:
351 logging.error('Failed to retrieve access token: %s' % content)
352 raise RequestError('Invalid response %s.' % resp['status'])