blob: 5ba7af88a5ccbd4fa40b8ed36a7d76da2649b0db [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:
116 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
122 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 }
181 resp, content = http_request(self.token_uri, method='POST', body=body, headers=headers)
182 if resp.status == 200:
183 # TODO(jcgregorio) Raise an error if loads fails?
184 d = simplejson.loads(content)
185 self.access_token = d['access_token']
186 self.refresh_token = d.get('refresh_token', self.refresh_token)
187 if 'expires_in' in d:
188 self.token_expiry = datetime.timedelta(seconds = int(d['expires_in'])) + datetime.datetime.now()
189 else:
190 self.token_expiry = None
191 if self.store is not None:
192 self.store(self)
193 else:
194 logging.error('Failed to retrieve access token: %s' % content)
195 raise RequestError('Invalid response %s.' % resp['status'])
196
197 def authorize(self, http):
198 """
199 Args:
200 http: An instance of httplib2.Http
201 or something that acts like it.
202
203 Returns:
204 A modified instance of http that was passed in.
205
206 Example:
207
208 h = httplib2.Http()
209 h = credentials.authorize(h)
210
211 You can't create a new OAuth
212 subclass of httplib2.Authenication because
213 it never gets passed the absolute URI, which is
214 needed for signing. So instead we have to overload
215 'request' with a closure that adds in the
216 Authorization header and then calls the original version
217 of 'request()'.
218 """
219 request_orig = http.request
220
221 # The closure that will replace 'httplib2.Http.request'.
222 def new_request(uri, method='GET', body=None, headers=None,
223 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
224 connection_type=None):
225 """Modify the request headers to add the appropriate
226 Authorization header."""
227 if headers == None:
228 headers = {}
Joe Gregorio49e94d82011-01-28 16:36:13 -0500229 headers['authorization'] = 'OAuth ' + self.access_token
Joe Gregorio695fdc12011-01-16 16:46:55 -0500230 if 'user-agent' in headers:
231 headers['user-agent'] = self.user_agent + ' ' + headers['user-agent']
232 else:
233 headers['user-agent'] = self.user_agent
234 resp, content = request_orig(uri, method, body, headers,
235 redirections, connection_type)
Joe Gregoriofd19cd32011-01-20 11:37:29 -0500236 if resp.status == 401:
Joe Gregorio695fdc12011-01-16 16:46:55 -0500237 logging.info("Refreshing because we got a 401")
238 self._refresh(request_orig)
239 return request_orig(uri, method, body, headers,
240 redirections, connection_type)
241 else:
242 return (resp, content)
243
244 http.request = new_request
245 return http
246
247
248class OAuth2WebServerFlow(Flow):
249 """Does the Web Server Flow for OAuth 2.0.
250
251 OAuth2Credentials objects may be safely pickled and unpickled.
252 """
253
254 def __init__(self, client_id, client_secret, scope, user_agent,
255 authorization_uri='https://www.google.com/accounts/o8/oauth2/authorization',
256 token_uri='https://www.google.com/accounts/o8/oauth2/token',
257 **kwargs):
258 """Constructor for OAuth2WebServerFlow
259
260 Args:
261 client_id: string, client identifier
262 client_secret: string client secret
263 scope: string, scope of the credentials being requested
264 user_agent: string, HTTP User-Agent to provide for this application.
265 authorization_uri: string, URI for authorization endpoint
266 token_uri: string, URI for token endpoint
267 **kwargs: dict, The keyword arguments are all optional and required
268 parameters for the OAuth calls.
269 """
270 self.client_id = client_id
271 self.client_secret = client_secret
272 self.scope = scope
273 self.user_agent = user_agent
274 self.authorization_uri = authorization_uri
275 self.token_uri = token_uri
276 self.params = kwargs
277 self.redirect_uri = None
278
279 def step1_get_authorize_url(self, redirect_uri='oob'):
280 """Returns a URI to redirect to the provider.
281
282 Args:
283 redirect_uri: string, Either the string 'oob' for a non-web-based
284 application, or a URI that handles the callback from
285 the authorization server.
286
287 If redirect_uri is 'oob' then pass in the
288 generated verification code to step2_exchange,
289 otherwise pass in the query parameters received
290 at the callback uri to step2_exchange.
291 """
292
293 self.redirect_uri = redirect_uri
294 query = {
295 'response_type': 'code',
296 'client_id': self.client_id,
297 'redirect_uri': redirect_uri,
298 'scope': self.scope,
299 }
300 query.update(self.params)
301 parts = list(urlparse.urlparse(self.authorization_uri))
302 query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part
303 parts[4] = urllib.urlencode(query)
304 return urlparse.urlunparse(parts)
305
306 def step2_exchange(self, code):
307 """Exhanges a code for OAuth2Credentials.
308
309 Args:
310 code: string or dict, either the code as a string, or a dictionary
311 of the query parameters to the redirect_uri, which contains
312 the code.
313 """
314
315 if not (isinstance(code, str) or isinstance(code, unicode)):
316 code = code['code']
317
318 body = urllib.urlencode({
319 'grant_type': 'authorization_code',
320 'client_id': self.client_id,
321 'client_secret': self.client_secret,
322 'code': code,
323 'redirect_uri': self.redirect_uri,
324 'scope': self.scope
325 })
326 headers = {
327 'user-agent': self.user_agent,
328 'content-type': 'application/x-www-form-urlencoded'
329 }
330 h = httplib2.Http()
331 resp, content = h.request(self.token_uri, method='POST', body=body, headers=headers)
332 if resp.status == 200:
333 # TODO(jcgregorio) Raise an error if simplejson.loads fails?
334 d = simplejson.loads(content)
335 access_token = d['access_token']
336 refresh_token = d.get('refresh_token', None)
337 token_expiry = None
338 if 'expires_in' in d:
339 token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in']))
340
341 logging.info('Successfully retrieved access token: %s' % content)
342 return OAuth2Credentials(access_token, self.client_id, self.client_secret,
343 refresh_token, token_expiry, self.token_uri,
344 self.user_agent)
345 else:
346 logging.error('Failed to retrieve access token: %s' % content)
347 raise RequestError('Invalid response %s.' % resp['status'])