diff --git a/README.md b/README.md index 3dca1e34..bc42d51e 100644 --- a/README.md +++ b/README.md @@ -355,3 +355,19 @@ Gmail supports OAuth over IMAP and SMTP via a standard they call XOAUTH. This al conn.authenticate(url, consumer, token) +# OAuth2 Example + +You might have thought from the name "oauth2" that this libary supported +the OAuth2 standard. You would have been wrong, except for the fine efforts +of https://github.com/dgouldin. Here is how you use it: + + import oauth2 + client = oauth2.Client2(CONSUMER_KEY, CONSUMER_SECRET, AUTHORIZATION_URL) + auth_url = client.authorization_url(redirect_uri = CALLBACK_URL) + print auth_url + # navigate to auth_url, to obtain code + token = client.access_token(code, CALLBACK_URL, endpoint='token')["access_token"] + print token + # use token to call secure APIs + (headers, content) = client.request(RESOURCE_URL, access_token=token) + ... diff --git a/oauth2/__init__.py b/oauth2/__init__.py index 835270e3..f95a8c6c 100644 --- a/oauth2/__init__.py +++ b/oauth2/__init__.py @@ -30,6 +30,7 @@ import hmac import binascii import httplib2 +import json try: from urlparse import parse_qs @@ -174,11 +175,11 @@ def generate_verifier(length=8): class Consumer(object): """A consumer of OAuth-protected services. - + The OAuth consumer is a "third-party" service that wants to access protected resources from an OAuth service provider on behalf of an end user. It's kind of the OAuth client. - + Usually a consumer must be registered with the service provider by the developer of the consumer software. As part of that process, the service provider gives the consumer a *key* and a *secret* with which the consumer @@ -186,7 +187,7 @@ class Consumer(object): key in each request to identify itself, but will use its secret only when signing requests, to prove that the request is from that particular registered consumer. - + Once registered, the consumer can then use its consumer credentials to ask the service provider for a request token, kicking off the OAuth authorization process. @@ -212,12 +213,12 @@ def __str__(self): class Token(object): """An OAuth credential used to request authorization or a protected resource. - + Tokens in OAuth comprise a *key* and a *secret*. The key is included in requests to identify the token being used, but the secret is used only in the signature, to prove that the requester is who the server gave the token to. - + When first negotiating the authorization, the consumer asks for a *request token* that the live user authorizes with the service provider. The consumer then exchanges the request token for an *access token* that can @@ -262,7 +263,7 @@ def get_callback_url(self): def to_string(self): """Returns this token as a plain string, suitable for storage. - + The resulting string includes the token's secret, so you should never send or store this string where a third party can read it. """ @@ -275,7 +276,7 @@ def to_string(self): if self.callback_confirmed is not None: data['oauth_callback_confirmed'] = self.callback_confirmed return urllib.urlencode(data) - + @staticmethod def from_string(s): """Deserializes a token from a string like one returned by @@ -296,7 +297,7 @@ def from_string(s): try: secret = params['oauth_token_secret'][0] except Exception: - raise ValueError("'oauth_token_secret' not found in " + raise ValueError("'oauth_token_secret' not found in " "OAuth request.") token = Token(key, secret) @@ -312,31 +313,31 @@ def __str__(self): def setter(attr): name = attr.__name__ - + def getter(self): try: return self.__dict__[name] except KeyError: raise AttributeError(name) - + def deleter(self): del self.__dict__[name] - + return property(getter, attr, deleter) class Request(dict): - + """The parameters and information for an HTTP request, suitable for authorizing with OAuth credentials. - + When a consumer wants to access a service's protected resources, it does so using a signed HTTP request identifying itself (the consumer) with its key, and providing an access token authorized by the end user to access those resources. - + """ - + version = OAUTH_VERSION def __init__(self, method=HTTP_METHOD, url=None, parameters=None, @@ -372,33 +373,33 @@ def url(self, value): else: self.normalized_url = None self.__dict__['url'] = None - + @setter def method(self, value): self.__dict__['method'] = value.upper() - + def _get_timestamp_nonce(self): return self['oauth_timestamp'], self['oauth_nonce'] - + def get_nonoauth_parameters(self): """Get any non-OAuth parameters.""" - return dict([(k, v) for k, v in self.iteritems() + return dict([(k, v) for k, v in self.iteritems() if not k.startswith('oauth_')]) - + def to_header(self, realm=''): """Serialize as a header for an HTTPAuth request.""" - oauth_params = ((k, v) for k, v in self.items() + oauth_params = ((k, v) for k, v in self.items() if k.startswith('oauth_')) stringy_params = ((k, escape(str(v))) for k, v in oauth_params) header_params = ('%s="%s"' % (k, v) for k, v in stringy_params) params_header = ', '.join(header_params) - + auth_header = 'OAuth realm="%s"' % realm if params_header: auth_header = "%s, %s" % (auth_header, params_header) - + return {'Authorization': auth_header} - + def to_postdata(self): """Serialize as post data for a POST request.""" d = {} @@ -500,24 +501,24 @@ def sign_request(self, signature_method, consumer, token): self['oauth_signature_method'] = signature_method.name self['oauth_signature'] = signature_method.sign(self, consumer, token) - + @classmethod def make_timestamp(cls): """Get seconds since epoch (UTC).""" return str(int(time.time())) - + @classmethod def make_nonce(cls): """Generate pseudorandom number.""" return str(random.randint(0, 100000000)) - + @classmethod def from_request(cls, http_method, http_url, headers=None, parameters=None, query_string=None): """Combines multiple parameter sources.""" if parameters is None: parameters = {} - + # Headers if headers and 'Authorization' in headers: auth_header = headers['Authorization'] @@ -531,61 +532,61 @@ def from_request(cls, http_method, http_url, headers=None, parameters=None, except: raise Error('Unable to parse OAuth parameters from ' 'Authorization header.') - + # GET or POST query string. if query_string: query_params = cls._split_url_string(query_string) parameters.update(query_params) - + # URL parameters. param_str = urlparse.urlparse(http_url)[4] # query url_params = cls._split_url_string(param_str) parameters.update(url_params) - + if parameters: return cls(http_method, http_url, parameters) - + return None - + @classmethod def from_consumer_and_token(cls, consumer, token=None, http_method=HTTP_METHOD, http_url=None, parameters=None, body='', is_form_encoded=False): if not parameters: parameters = {} - + defaults = { 'oauth_consumer_key': consumer.key, 'oauth_timestamp': cls.make_timestamp(), 'oauth_nonce': cls.make_nonce(), 'oauth_version': cls.version, } - + defaults.update(parameters) parameters = defaults - + if token: parameters['oauth_token'] = token.key if token.verifier: parameters['oauth_verifier'] = token.verifier - + return Request(http_method, http_url, parameters, body=body, is_form_encoded=is_form_encoded) @classmethod - def from_token_and_callback(cls, token, callback=None, + def from_token_and_callback(cls, token, callback=None, http_method=HTTP_METHOD, http_url=None, parameters=None): if not parameters: parameters = {} - + parameters['oauth_token'] = token.key - + if callback: parameters['oauth_callback'] = callback - + return cls(http_method, http_url, parameters) - + @staticmethod def _split_header(header): """Turn Authorization: header into parameters.""" @@ -602,7 +603,7 @@ def _split_header(header): # Remove quotes and unescape the value. params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) return params - + @staticmethod def _split_url_string(param_str): """Turn URL string into parameters.""" @@ -685,7 +686,7 @@ def request(self, uri, method="GET", body='', headers=None, class Server(object): """A skeletal implementation of a service provider, providing protected resources to requests from authorized consumers. - + This class implements the logic to check requests for authorization. You can use it with your web server or web framework to protect certain resources with OAuth. @@ -764,7 +765,7 @@ def _check_signature(self, request, consumer, token): if not valid: key, base = signature_method.signing_base(request, consumer, token) - raise Error('Invalid signature. Expected signature base ' + raise Error('Invalid signature. Expected signature base ' 'string: %s' % base) def _check_timestamp(self, timestamp): @@ -774,13 +775,13 @@ def _check_timestamp(self, timestamp): lapsed = now - timestamp if lapsed > self.timestamp_threshold: raise Error('Expired timestamp: given %d and now %s has a ' - 'greater difference than threshold %d' % (timestamp, now, + 'greater difference than threshold %d' % (timestamp, now, self.timestamp_threshold)) class SignatureMethod(object): """A way of signing requests. - + The OAuth protocol lets consumers and service providers pick a way to sign requests. This interface shows the methods expected by the other `oauth` modules for signing requests. Subclass it and implement its methods to @@ -858,3 +859,160 @@ def signing_base(self, request, consumer, token): def sign(self, request, consumer, token): key, raw = self.signing_base(request, consumer, token) return raw + + +class Client2(object): + """Client for OAuth 2.0 draft spec + https://svn.tools.ietf.org/html/draft-hammer-oauth2-00 + """ + + def __init__(self, client_id, client_secret, oauth_base_url, + redirect_uri=None, cache=None, timeout=None, proxy_info=None): + + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.oauth_base_url = oauth_base_url + + if self.client_id is None or self.client_secret is None or \ + self.oauth_base_url is None: + raise ValueError("Client_id and client_secret must be set.") + + self.http = httplib2.Http(cache=cache, timeout=timeout, + proxy_info=proxy_info) + + @staticmethod + def _split_url_string(param_str): + """Turn URL string into parameters.""" + parameters = parse_qs(param_str, keep_blank_values=False) + for key, val in parameters.iteritems(): + parameters[key] = urllib.unquote(val[0]) + return parameters + + @staticmethod + def _get_json(data): + """Turn json response into hash.""" + result = json.loads(data) + return result + + + def authorization_url(self, redirect_uri=None, params=None, state=None, + immediate=None, endpoint='authorize'): + """Get the URL to redirect the user for client authorization + https://svn.tools.ietf.org/html/draft-hammer-oauth2-00#section-3.5.2.1 + """ + + # prepare required args + args = { + 'type': 'web_server', + 'client_id': self.client_id, + } + + # prepare optional args + redirect_uri = redirect_uri or self.redirect_uri + if redirect_uri is not None: + args['redirect_uri'] = redirect_uri + if state is not None: + args['state'] = state + if immediate is not None: + args['immediate'] = str(immediate).lower() + + args.update(params or {}) + + return '%s?%s' % (urlparse.urljoin(self.oauth_base_url, endpoint), + urllib.urlencode(args)) + + def access_token(self, code, redirect_uri, params=None, secret_type=None, + endpoint='access_token'): + """Get an access token from the supplied code + https://svn.tools.ietf.org/html/draft-hammer-oauth2-00#section-3.5.2.2 + """ + + # prepare required args + if code is None: + raise ValueError("Code must be set.") + if redirect_uri is None: + raise ValueError("Redirect_uri must be set.") + args = { + 'type': 'web_server', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': redirect_uri, + } + + # prepare optional args + if secret_type is not None: + args['secret_type'] = secret_type + + args.update(params or {}) + + uri = urlparse.urljoin(self.oauth_base_url, endpoint) + body = urllib.urlencode(args) + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + response, content = self.http.request(uri, method='POST', body=body, + headers=headers) + if not response.status == 200: + raise Error(content) + + if "json" in response['content-type']: + response_args = Client2._get_json(content) + else: + response_args = Client2._split_url_string(content) + + error = response_args.pop('error', None) + if error is not None: + raise Error(error) + + return response_args + + def refresh(self, refresh_token, params=None, secret_type=None, + endpoint='access_token'): + """Get a new access token from the supplied refresh token + https://svn.tools.ietf.org/html/draft-hammer-oauth2-00#section-4 + """ + + if refresh_token is None: + raise ValueError("Refresh_token must be set.") + + # prepare required args + args = { + 'type': 'refresh', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'refresh_token': refresh_token, + } + + # prepare optional args + if secret_type is not None: + args['secret_type'] = secret_type + + args.update(params or {}) + + uri = urlparse.urljoin(self.oauth_base_url, endpoint) + body = urllib.urlencode(args) + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + response, content = self.http.request(uri, method='POST', body=body, + headers=headers) + if not response.status == 200: + raise Error(content) + + response_args = Client2._split_url_string(content) + return response_args + + def request(self, base_uri, access_token=None, method='GET', body=None, + headers=None, params=None, token_param='oauth_token'): + """Make a request to the OAuth API""" + + args = {} + args.update(params or {}) + if access_token is not None and method == 'GET': + args[token_param] = access_token + uri = '%s?%s' % (base_uri, urllib.urlencode(args)) + return self.http.request(uri, method=method, body=body, headers=headers)