├── dev-requirements.txt ├── MANIFEST.in ├── tox.ini ├── requirements.txt ├── .gitignore ├── pyramid_oauth2_provider ├── scripts │ ├── __init__.py │ ├── initializedb.py │ └── create_client_credentials.py ├── generators.py ├── interfaces.py ├── __init__.py ├── util.py ├── errors.py ├── jsonerrors.py ├── authentication.py ├── models.py ├── views.py └── tests.py ├── setup.cfg ├── LICENSE ├── production.ini ├── development.ini ├── setup.py ├── README.md └── example └── client.py /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | tox==2.3.1 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.ini *.cfg README.md 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34 3 | 4 | [testenv] 5 | commands = python setup.py test 6 | 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyramid 2 | SQLAlchemy 3 | transaction 4 | pyramid_tm 5 | pyramid_debugtoolbar 6 | six==1.10.0 7 | zope.sqlalchemy 8 | zope.interface 9 | waitress 10 | cryptography -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | # *~ 7 | *.egg-info 8 | *.pyc 9 | *.swo 10 | *.swp 11 | .coverage 12 | .eggs 13 | .tox 14 | build/* 15 | dist/* 16 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | match=^test 3 | nocapture=1 4 | cover-package=pyramid_oauth2_provider 5 | with-coverage=1 6 | cover-erase=1 7 | 8 | [compile_catalog] 9 | directory = pyramid_oauth2_provider/locale 10 | domain = pyramid_oauth2_provider 11 | statistics = true 12 | 13 | [extract_messages] 14 | add_comments = TRANSLATORS: 15 | output_file = pyramid_oauth2_provider/locale/pyramid_oauth2_provider.pot 16 | width = 80 17 | 18 | [init_catalog] 19 | domain = pyramid_oauth2_provider 20 | input_file = pyramid_oauth2_provider/locale/pyramid_oauth2_provider.pot 21 | output_dir = pyramid_oauth2_provider/locale 22 | 23 | [update_catalog] 24 | domain = pyramid_oauth2_provider 25 | input_file = pyramid_oauth2_provider/locale/pyramid_oauth2_provider.pot 26 | output_dir = pyramid_oauth2_provider/locale 27 | previous = true 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/generators.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | 14 | import time 15 | import random 16 | import hashlib 17 | 18 | def _get_hash(): 19 | sha = hashlib.sha256() 20 | sha.update(str(random.random()).encode('utf8')) 21 | sha.update(str(time.time()).encode('utf8')) 22 | return sha 23 | 24 | def gen_client_id(): 25 | return _get_hash().hexdigest() 26 | 27 | def gen_client_secret(): 28 | return _get_hash().hexdigest() 29 | 30 | def gen_token(client): 31 | sha = _get_hash() 32 | sha.update(client.client_id.encode('utf8')) 33 | return sha.hexdigest() 34 | 35 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/interfaces.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | from zope.interface import Interface 14 | 15 | class IAuthCheck(Interface): 16 | """ 17 | This interface is for verifying authentication information with your 18 | backing store of choice. In the short term this will be limited to 19 | usernames and passwords, but may grow to support other authentication 20 | methods. 21 | """ 22 | 23 | def checkauth(self, username, password): 24 | """ 25 | Validate a given username and password against some kind of store, 26 | usually a relational database. Return the users user_id if credentials 27 | are valid, otherwise False or None. 28 | """ 29 | -------------------------------------------------------------------------------- /production.ini: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = egg:pyramid_oauth2_provider 3 | 4 | pyramid.reload_templates = false 5 | pyramid.debug_authorization = false 6 | pyramid.debug_notfound = false 7 | pyramid.debug_routematch = false 8 | pyramid.default_locale_name = en 9 | pyramid.includes = 10 | pyramid_tm 11 | 12 | sqlalchemy.url = sqlite:///%(here)s/pyramid_oauth2_provider.db 13 | 14 | [server:main] 15 | use = egg:waitress#main 16 | host = 0.0.0.0 17 | port = 6543 18 | 19 | # Begin logging configuration 20 | 21 | [loggers] 22 | keys = root, pyramid_oauth2_provider, sqlalchemy 23 | 24 | [handlers] 25 | keys = console 26 | 27 | [formatters] 28 | keys = generic 29 | 30 | [logger_root] 31 | level = WARN 32 | handlers = console 33 | 34 | [logger_pyramid_oauth2_provider] 35 | level = WARN 36 | handlers = 37 | qualname = pyramid_oauth2_provider 38 | 39 | [logger_sqlalchemy] 40 | level = WARN 41 | handlers = 42 | qualname = sqlalchemy.engine 43 | # "level = INFO" logs SQL queries. 44 | # "level = DEBUG" logs SQL queries and results. 45 | # "level = WARN" logs neither. (Recommended for production systems.) 46 | 47 | [handler_console] 48 | class = StreamHandler 49 | args = (sys.stderr,) 50 | level = NOTSET 51 | formatter = generic 52 | 53 | [formatter_generic] 54 | format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s 55 | 56 | # End logging configuration 57 | -------------------------------------------------------------------------------- /development.ini: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = egg:pyramid_oauth2_provider 3 | 4 | pyramid.reload_templates = true 5 | pyramid.debug_authorization = false 6 | pyramid.debug_notfound = false 7 | pyramid.debug_routematch = false 8 | pyramid.default_locale_name = en 9 | pyramid.includes = 10 | pyramid_debugtoolbar 11 | pyramid_tm 12 | 13 | sqlalchemy.url = sqlite:///%(here)s/pyramid_oauth2_provider.db 14 | 15 | [server:main] 16 | use = egg:waitress#main 17 | host = 0.0.0.0 18 | port = 6543 19 | 20 | # Begin logging configuration 21 | 22 | [loggers] 23 | keys = root, pyramid_oauth2_provider, sqlalchemy 24 | 25 | [handlers] 26 | keys = console 27 | 28 | [formatters] 29 | keys = generic 30 | 31 | [logger_root] 32 | level = INFO 33 | handlers = console 34 | 35 | [logger_pyramid_oauth2_provider] 36 | level = DEBUG 37 | handlers = 38 | qualname = pyramid_oauth2_provider 39 | 40 | [logger_sqlalchemy] 41 | level = INFO 42 | handlers = 43 | qualname = sqlalchemy.engine 44 | # "level = INFO" logs SQL queries. 45 | # "level = DEBUG" logs SQL queries and results. 46 | # "level = WARN" logs neither. (Recommended for production systems.) 47 | 48 | [handler_console] 49 | class = StreamHandler 50 | args = (sys.stderr,) 51 | level = NOTSET 52 | formatter = generic 53 | 54 | [formatter_generic] 55 | format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s 56 | 57 | # End logging configuration 58 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/scripts/initializedb.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import os 14 | import sys 15 | import json 16 | 17 | from sqlalchemy import engine_from_config 18 | 19 | from pyramid.paster import ( 20 | get_appsettings, 21 | setup_logging, 22 | ) 23 | 24 | from ..models import ( 25 | DBSession, 26 | Base, 27 | ) 28 | 29 | def usage(argv): 30 | cmd = os.path.basename(argv[0]) 31 | print(('usage: %s \n' 32 | '(example: "%s development.ini false")' % (cmd, cmd))) 33 | sys.exit(1) 34 | 35 | def main(argv=sys.argv): 36 | if len(argv) != 3: 37 | usage(argv) 38 | config_uri = argv[1] 39 | drop = json.loads(argv[2].lower()) 40 | setup_logging(config_uri) 41 | settings = get_appsettings(config_uri) 42 | engine = engine_from_config(settings, 'sqlalchemy.') 43 | DBSession.configure(bind=engine) 44 | if drop: 45 | Base.metadata.drop_all(engine) 46 | Base.metadata.create_all(engine) 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | README = open(os.path.join(here, 'README.md')).read() 7 | 8 | requires = [ 9 | 'pyramid', 10 | 'SQLAlchemy', 11 | 'transaction', 12 | 'pyramid_tm', 13 | 'pyramid_debugtoolbar', 14 | 'six==1.10.0', 15 | 'zope.sqlalchemy', 16 | 'zope.interface', 17 | 'waitress', 18 | 'cryptography' 19 | ] 20 | 21 | setup(name='pyramid_oauth2_provider', 22 | version='0.2', 23 | description='Oauth2 endpoint for pyramid applications', 24 | long_description=README, 25 | classifiers=[ 26 | "Programming Language :: Python", 27 | "Framework :: Pyramid", 28 | "Topic :: Internet :: WWW/HTTP", 29 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 30 | ], 31 | author='Elliot Peele', 32 | author_email='elliot@bentlogic.net', 33 | url='http://github.com/elliotpeele/pyramid_oauth2_provider', 34 | keywords='web wsgi bfg pylons pyramid oauth2', 35 | packages=find_packages(), 36 | include_package_data=True, 37 | zip_safe=False, 38 | test_suite='pyramid_oauth2_provider', 39 | install_requires=requires, 40 | entry_points="""\ 41 | [paste.app_factory] 42 | main = pyramid_oauth2_provider:main 43 | [console_scripts] 44 | initialize_pyramid_oauth2_provider_db = pyramid_oauth2_provider.scripts.initializedb:main 45 | create_client_credentials=pyramid_oauth2_provider.scripts.create_client_credentials:main 46 | """, 47 | ) 48 | 49 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/scripts/create_client_credentials.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import os 14 | import sys 15 | 16 | import transaction 17 | 18 | from sqlalchemy import engine_from_config 19 | 20 | from pyramid.paster import ( 21 | get_appsettings, 22 | setup_logging, 23 | ) 24 | 25 | from pyramid_oauth2_provider.models import ( 26 | DBSession, 27 | initialize_sql, 28 | Oauth2Client, 29 | ) 30 | 31 | def create_client(salt=None): 32 | client = Oauth2Client(salt=salt) 33 | client_secret = client.new_client_secret() 34 | DBSession.add(client) 35 | return client.client_id, client_secret 36 | 37 | def usage(argv): 38 | cmd = os.path.basename(argv[0]) 39 | print(('usage: %s
\n' 40 | '(example: "%s development.ini myproject")' % (cmd, cmd))) 41 | sys.exit(1) 42 | 43 | def main(argv=sys.argv): 44 | if len(argv) != 3: 45 | usage(argv) 46 | config_uri = argv[1] 47 | section = argv[2] 48 | setup_logging(config_uri) 49 | settings = get_appsettings(config_uri, section) 50 | engine = engine_from_config(settings, 'sqlalchemy.') 51 | try: 52 | salt = settings['oauth2_provider.salt'] 53 | except KeyError: 54 | raise ValueError( 55 | 'oauth2_provider.salt configuration required.' 56 | ) 57 | initialize_sql(engine, settings) 58 | 59 | with transaction.manager: 60 | id, secret = create_client(salt=salt) 61 | print('client_id:', id) 62 | print('client_secret:', secret) 63 | 64 | if __name__ == '__main__': 65 | main() 66 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | from sqlalchemy import engine_from_config 14 | 15 | from pyramid.config import Configurator 16 | from pyramid.exceptions import ConfigurationError 17 | from pyramid.interfaces import IAuthenticationPolicy 18 | 19 | from .models import initialize_sql 20 | from .interfaces import IAuthCheck 21 | from .authentication import OauthAuthenticationPolicy 22 | 23 | # imported to make the test runnner happy 24 | from . import tests 25 | 26 | def includeme(config): 27 | settings = config.registry.settings 28 | engine = engine_from_config(settings, 'sqlalchemy.') 29 | 30 | initialize_sql(engine, settings) 31 | 32 | if not config.registry.queryUtility(IAuthenticationPolicy): 33 | config.set_authentication_policy(OauthAuthenticationPolicy()) 34 | 35 | auth_check = settings.get('oauth2_provider.auth_checker') 36 | if not auth_check: 37 | raise ConfigurationError('You must provide an implementation of the ' 38 | 'authentication check interface that is included with ' 39 | 'pyramid_oauth2_provider for verifying usernames and passwords') 40 | 41 | policy = config.maybe_dotted(auth_check) 42 | config.registry.registerUtility(policy, IAuthCheck) 43 | 44 | config.add_route('oauth2_provider_authorize', '/oauth2/authorize') 45 | config.add_route('oauth2_provider_token', '/oauth2/token') 46 | config.scan() 47 | 48 | def main(global_config, **settings): 49 | """ This function returns a Pyramid WSGI application. 50 | """ 51 | config = Configurator(settings=settings) 52 | includeme(config) 53 | return config.make_wsgi_app() 54 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/util.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import base64 14 | import logging 15 | 16 | from pyramid.threadlocal import get_current_registry 17 | 18 | log = logging.getLogger('pyramid_oauth2_provider.util') 19 | 20 | def oauth2_settings(key=None, default=None): 21 | settings = get_current_registry().settings 22 | 23 | if key: 24 | value = settings.get('oauth2_provider.%s' % key, default) 25 | if value == 'true': 26 | return True 27 | elif value == 'false': 28 | return False 29 | else: 30 | return value 31 | else: 32 | return dict((x.split('.', 1)[1], y) for x, y in settings.items() 33 | if x.startswith('oauth2_provider.')) 34 | 35 | def getClientCredentials(request): 36 | if 'Authorization' in request.headers: 37 | auth = request.headers.get('Authorization') 38 | elif 'authorization' in request.headers: 39 | auth = request.headers.get('authorization') 40 | else: 41 | log.debug('no authorization header found') 42 | return False 43 | 44 | if (not auth.lower().startswith('bearer') and 45 | not auth.lower().startswith('basic')): 46 | log.debug('authorization header not of type bearer or basic: %s' 47 | % auth.lower()) 48 | return False 49 | 50 | parts = auth.split() 51 | if len(parts) != 2: 52 | return False 53 | 54 | token_type = parts[0].lower() 55 | token = base64.b64decode(parts[1]).decode('utf8') 56 | 57 | if token_type == 'basic': 58 | client_id, client_secret = token.split(':') 59 | request.client_id = client_id 60 | request.client_secret = client_secret 61 | 62 | return token_type, token 63 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | class BaseOauth2Error(dict): 14 | error_name = None 15 | 16 | def __init__(self, **kw): 17 | dict.__init__(self) 18 | if kw: 19 | self.update(kw) 20 | self['error'] = self.error_name 21 | 22 | if 'error_description' not in self: 23 | self['error_description'] = self.__doc__ 24 | 25 | 26 | class InvalidRequest(BaseOauth2Error): 27 | """ 28 | The request is missing a required parameter, includes an unsupported 29 | parameter or parameter value, repeats the same parameter, uses more 30 | than one method for including an access token, or is otherwise 31 | malformed. The resource server SHOULD respond with the HTTP 400 32 | (Bad Request) status code. 33 | 34 | http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-23#section-3.1 35 | """ 36 | error_name = 'invalid_request' 37 | 38 | 39 | class InvalidClient(BaseOauth2Error): 40 | """ 41 | The provided authorization grant is invalid, expired, revoked, or 42 | was issued to another cilent. 43 | 44 | http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-5.2 45 | """ 46 | error_name = 'invalid_client' 47 | 48 | 49 | class UnauthorizedClient(BaseOauth2Error): 50 | """ 51 | The authenticated user is not authorized to use this authorization 52 | grant type. 53 | 54 | http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-5.2 55 | """ 56 | error_name = 'unauthorized_client' 57 | 58 | 59 | class UnsupportedGrantType(BaseOauth2Error): 60 | """ 61 | The authorizaiton grant type is not supported by the authorization 62 | server. 63 | 64 | http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-5.2 65 | """ 66 | error_name = 'unsupported_grant_type' 67 | 68 | 69 | class InvalidToken(BaseOauth2Error): 70 | """ 71 | The access token provided is expired, revoked, malformed, or 72 | invalid for other reasons. The resource SHOULD respond with the 73 | HTTP 401 (Unauthorized) status code. The client MAY request a new 74 | access token and retry the protected resource request. 75 | 76 | http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-23#section-3.1 77 | """ 78 | error_name = 'invalid_token' 79 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/jsonerrors.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | """ 14 | Custom HTTP exceptions that support rendering to JSON by default. 15 | """ 16 | 17 | # NOTE: If you need to add more errors, please subclass errors from 18 | # httpexceptions as has been done below. 19 | 20 | from string import Template 21 | 22 | from pyramid import httpexceptions 23 | from pyramid.httpexceptions import text_type 24 | from pyramid.httpexceptions import _no_escape 25 | from pyramid.httpexceptions import WSGIHTTPException 26 | 27 | def _quote_escape(value): 28 | v = _no_escape(value) 29 | return v.replace('"', '\\"') 30 | 31 | 32 | class BaseJsonHTTPError(WSGIHTTPException): 33 | """ 34 | Base error class for rendering errors in JSON. 35 | """ 36 | 37 | json_template_obj = Template('''\ 38 | { 39 | "status": "${status}", 40 | "code": ${code}, 41 | "explanation": "${explanation}", 42 | "detail": "${detail}" 43 | } 44 | ${html_comment} 45 | ''') 46 | 47 | def prepare(self, environ): 48 | """ 49 | Always return errors in JSON. 50 | """ 51 | 52 | if not self.body and not self.empty_body: 53 | html_comment = '' 54 | comment = self.comment or '' 55 | accept = environ.get('HTTP_ACCEPT', '') 56 | if 'text/plain' in accept: 57 | self.content_type = 'text/plain' 58 | escape = _no_escape 59 | page_template = self.plain_template_obj 60 | br = '\n' 61 | if comment: 62 | html_comment = escape(comment) 63 | else: 64 | self.content_type = 'aplication/json' 65 | escape = _quote_escape 66 | page_template = self.json_template_obj 67 | br = '\n' 68 | if comment: 69 | html_comment = '# %s' % comment 70 | args = { 71 | 'br': br, 72 | 'explanation': escape(self.explanation), 73 | 'detail': escape(self.detail or ''), 74 | 'comment': escape(comment), 75 | 'html_comment': html_comment, 76 | } 77 | for k, v in list(environ.items()): 78 | if (not k.startswith('wsgi.')) and ('.' in k): 79 | continue 80 | args[k] = escape(v) 81 | for k, v in list(self.headers.items()): 82 | args[k.lower()] = escape(v) 83 | page = page_template.substitute(status=self.status, 84 | code=self.code, **args) 85 | if isinstance(page, text_type): 86 | page = page.encode(self.charset) 87 | self.app_iter = [page] 88 | self.body = page 89 | 90 | 91 | class HTTPBadRequest(httpexceptions.HTTPBadRequest, BaseJsonHTTPError): 92 | pass 93 | 94 | 95 | class HTTPUnauthorized(httpexceptions.HTTPUnauthorized, BaseJsonHTTPError): 96 | pass 97 | 98 | 99 | class HTTPMethodNotAllowed(httpexceptions.HTTPMethodNotAllowed, 100 | BaseJsonHTTPError): 101 | pass 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pyramid_oauth2_provider README 2 | ============================== 3 | 4 | 5 | ##### Warning: 6 | You will need to reset your DB tables related to this library and 7 | provide a new ini config 'oauth2_provider.salt' when upgrading from v0.2.0. 8 | To reset the tables, run the init script with added boolean argument to drop: 9 | 10 | initialize_pyramid_oauth2_provider_db-script.py development.ini true 11 | 12 | Additionally, scrypt requires OpenSSL v1.1.0 or newer. 13 | 14 | Getting Started 15 | --------------- 16 | 17 | In an existing pyramid project you can take advantage of pyramid_oauth2_provider 18 | by doing the following: 19 | 20 | * Add `config.include('pyramid_oauth2_provider')` to your project setup. This 21 | will configure a `/oauth2/token` route for the token endpoint and an 22 | authentication policy that will support oauth2. If you want to be able to use 23 | both cookie auth and oauth2 at the same time, you should use the 24 | `pyramid_oauth2_provider.authentication.OauthTktAuthenticationPolicy` instead 25 | of the default. 26 | * Define a implementation of the `pyramid_oauth2_provider.interfaces.IAuthCheck` 27 | interface that works against your current user authentication check mechanism. 28 | * In your paster configuration configure which IAuthCheck implementation to use 29 | by specifying `oauth2_provider.auth_checker`. 30 | * In your production/development configuration, set a 16 random byte, base64 31 | encoded salt for scrypt: 32 | 33 | oauth2_provider.salt = REPLACEME 34 | 35 | How to generate a salt in Python: 36 | 37 | from base64 import b64encode 38 | b64encode(os.urandom(16)).decode('utf-8') 39 | 40 | * In your development configuration, you may also want to disable ssl 41 | enforcement by specifying `oauth2_provider.require_ssl = false`. 42 | * Generate client credentials using the `create_client_credentials` script, 43 | provided as part of `pyramid_oauth2_provider`. 44 | 45 | Request Flow 46 | ------------ 47 | Let's start by laying out a few ground rules when it comes to oauth2: 48 | 49 | 1. All requests *must* be made via HTTPS. 50 | 2. All data is transferred in headers and the body of messages rather than 51 | through url parameters. 52 | 53 | The token endpoint is provided as a way to obtain and renew `access_tokens`. 54 | 55 | #### Example initial token request: 56 | 57 | POST /oauth2/token HTTP/1.1 58 | Host: server.example.com 59 | Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW 60 | Content-Type: application/x-www-form-urlencoded 61 | 62 | grant_type=password&username=johndoe&password=A3ddj3w 63 | 64 | * The basic auth header is the `client_id:client_secret` base64 encoded. 65 | * Content-Type must be application/x-www-form-urlencoded 66 | 67 | #### Example refresh token request: 68 | 69 | POST /token HTTP/1.1 70 | Host: server.example.com 71 | Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW 72 | Content-Type: application/x-www-form-urlencoded 73 | 74 | grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKW&user_id=1234 75 | 76 | * The basic auth header is the `client_id:client_secret` base64 encoded. 77 | * Content-Type must be application/x-www-form-urlencoded 78 | * The `grant_type` must be "refresh". 79 | * All form elements are required. 80 | 81 | #### Example token response: 82 | 83 | HTTP/1.1 200 OK 84 | Content-Type: application/json;charset=UTF-8 85 | Cache-Control: no-store 86 | Pragma: no-cache 87 | 88 | { 89 | "access_token":"2YotnFZFEjr1zCsicMWpAA", 90 | "token_type":"bearer", 91 | "expires_in":3600, 92 | "refresh_token":"tGzv3JOkF0XG5Qx2TlKW", 93 | "user_id":1234, 94 | } 95 | 96 | * The same response is returned for both auth token and refresh token requests. 97 | * The `token_type` will always be "bearer". 98 | * For purposes of this example the `access_token` and `refresh_token` are 99 | shorter than normal. 100 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/authentication.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import logging 14 | 15 | from zope.interface import implementer 16 | 17 | from pyramid.interfaces import IAuthenticationPolicy 18 | from pyramid.authentication import AuthTktAuthenticationPolicy 19 | from pyramid.authentication import CallbackAuthenticationPolicy 20 | 21 | from pyramid.httpexceptions import HTTPBadRequest 22 | from pyramid.httpexceptions import HTTPUnauthorized 23 | 24 | from .models import Oauth2Token 25 | from .models import DBSession as db 26 | from .errors import InvalidToken 27 | from .errors import InvalidRequest 28 | from .util import getClientCredentials 29 | 30 | log = logging.getLogger('pyramid_oauth2_provider.authentication') 31 | 32 | @implementer(IAuthenticationPolicy) 33 | class OauthAuthenticationPolicy(CallbackAuthenticationPolicy): 34 | def _isOauth(self, request): 35 | return bool(getClientCredentials(request)) 36 | 37 | def _get_auth_token(self, request): 38 | try: 39 | token_type, token = getClientCredentials(request) 40 | except TypeError: 41 | raise HTTPBadRequest(InvalidRequest()) 42 | 43 | if token_type != 'bearer': 44 | return None 45 | 46 | auth_token = db.query(Oauth2Token).filter_by(access_token=token).first() 47 | # Bad input, return 400 Invalid Request 48 | if not auth_token: 49 | raise HTTPBadRequest(InvalidRequest()) 50 | # Expired or revoked token, return 401 invalid token 51 | if auth_token.isRevoked(): 52 | raise HTTPUnauthorized(InvalidToken()) 53 | 54 | return auth_token 55 | 56 | def unauthenticated_userid(self, request): 57 | auth_token = self._get_auth_token(request) 58 | if not auth_token: 59 | return None 60 | 61 | return auth_token.user_id 62 | 63 | def remember(self, request, principal, **kw): 64 | """ 65 | I don't think there is anything to do for an oauth request here. 66 | """ 67 | 68 | def forget(self, request): 69 | auth_token = self._get_auth_token(request) 70 | if not auth_token: 71 | return None 72 | 73 | auth_token.revoke() 74 | 75 | 76 | @implementer(IAuthenticationPolicy) 77 | class OauthTktAuthenticationPolicy(OauthAuthenticationPolicy, 78 | AuthTktAuthenticationPolicy): 79 | def __init__(self, *args, **kwargs): 80 | OauthAuthenticationPolicy.__init__(self) 81 | AuthTktAuthenticationPolicy.__init__(self, *args, **kwargs) 82 | 83 | def unauthenticated_userid(self, request): 84 | if self._isOauth(request): 85 | return OauthAuthenticationPolicy.unauthenticated_userid( 86 | self, request) 87 | else: 88 | return AuthTktAuthenticationPolicy.unauthenticated_userid( 89 | self, request) 90 | 91 | def remember(self, request, principal, **kw): 92 | if self._isOauth(request): 93 | return OauthAuthenticationPolicy.remember( 94 | self, request, principal, **kw) 95 | else: 96 | return AuthTktAuthenticationPolicy.remember( 97 | self, request, principal, **kw) 98 | 99 | def forget(self, request): 100 | if self._isOauth(request): 101 | return OauthAuthenticationPolicy.forget( 102 | self, request) 103 | else: 104 | return AuthTktAuthenticationPolicy.forget( 105 | self, request) 106 | -------------------------------------------------------------------------------- /example/client.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import sys 14 | import copy 15 | import base64 16 | import logging 17 | import requests 18 | from collections import namedtuple 19 | 20 | log = logging.getLogger('example_client') 21 | 22 | class Token(namedtuple('Token', 'token_type access_token expires_in ' 23 | 'refresh_token user_id')): 24 | __slots__ = () 25 | 26 | @classmethod 27 | def fromdict(cls, d): 28 | return cls( 29 | d['token_type'], 30 | d['access_token'], 31 | d['expires_in'], 32 | d['refresh_token'], 33 | d['user_id'] 34 | ) 35 | 36 | 37 | class Client(object): 38 | def __init__(self, client_id, client_secret, token_endpoint, 39 | verifySSL=True): 40 | 41 | self.client_id = client_id 42 | self.client_secret = client_secret 43 | self.token_endpoint = token_endpoint 44 | self.verifySSL = verifySSL 45 | 46 | self.token = None 47 | 48 | def _get_client_auth_header(self): 49 | return { 50 | 'Content-Type': 'application/x-www-form-urlencoded', 51 | 'Authorization': 'Basic %s' % base64.b64encode('%s:%s' 52 | % (self.client_id, self.client_secret)), 53 | } 54 | 55 | def login(self, username, password): 56 | data = { 57 | 'grant_type': 'password', 58 | 'username': username, 59 | 'password': password, 60 | } 61 | resp = requests.post(self.token_endpoint, data=data, 62 | headers=self._get_client_auth_header(), 63 | verify=self.verifySSL, )#config=dict(verbose=log.debug)) 64 | 65 | if not resp.ok: 66 | raise RuntimeError 67 | 68 | self.token = Token.fromdict(resp.json()) 69 | 70 | def refresh_login(self): 71 | data = { 72 | 'grant_type': 'refresh_token', 73 | 'refresh_token': self.token.refresh_token, 74 | 'user_id': self.token.user_id, 75 | } 76 | resp = requests.post(self.token_endpoint, data=data, 77 | headers=self._get_client_auth_header(), 78 | verify=self.verifySSL, )#config=dict(verbose=log.debug)) 79 | 80 | if not resp.ok: 81 | raise RuntimeError 82 | 83 | self.token = Token.fromdict(resp.json()) 84 | 85 | def _get_token_auth_header(self): 86 | return { 87 | 'Authorization': '%s %s' % (self.token.token_type, 88 | base64.b64encode(self.token.access_token)) 89 | } 90 | 91 | def _handle_request(self, method, uri, data=None, headers=None): 92 | if not headers: 93 | headers = {} 94 | else: 95 | headers = copy.copy(headers) 96 | 97 | headers.update(self._get_token_auth_header()) 98 | 99 | handler = getattr(requests, method) 100 | resp = handler(uri, data=data, headers=headers, verify=self.verifySSL, 101 | )#config=dict(verbose=log.debug)) 102 | 103 | return resp 104 | 105 | def get(self, *args, **kwargs): 106 | return self._handle_request('get', *args, **kwargs) 107 | 108 | def post(self, *args, **kwargs): 109 | return self._handle_request('post', *args, **kwargs) 110 | 111 | def put(self, *args, **kwargs): 112 | return self._handle_request('put', *args, **kwargs) 113 | 114 | def delete(self, *args, **kwargs): 115 | return self._handle_request('delete', *args, **kwargs) 116 | 117 | 118 | def usage(args): 119 | print >>sys.stderr, ('usage: %s ' 120 | ' ' % args[0]) 121 | return 1 122 | 123 | def main(args): 124 | if len(args) != 6: 125 | return usage(args) 126 | 127 | client_id = args[1] 128 | client_secret = args[2] 129 | token_uri = args[3] 130 | username = args[4] 131 | password = args[5] 132 | 133 | client = Client(client_id, client_secret, token_uri, verifySSL=False) 134 | client.login(username, password) 135 | client.refresh_login() 136 | 137 | return 0 138 | 139 | if __name__ == '__main__': 140 | sys.exit(main(sys.argv)) 141 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/models.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import time 14 | from datetime import datetime 15 | from base64 import b64decode 16 | 17 | from sqlalchemy import Column 18 | from sqlalchemy import ForeignKey 19 | 20 | from sqlalchemy import Binary 21 | from sqlalchemy import String 22 | from sqlalchemy import Integer 23 | from sqlalchemy import Boolean 24 | from sqlalchemy import DateTime 25 | from sqlalchemy import Unicode 26 | 27 | from sqlalchemy.ext.declarative import declarative_base 28 | 29 | from sqlalchemy.orm import backref 30 | from sqlalchemy.orm import relationship 31 | from sqlalchemy.orm import sessionmaker 32 | from sqlalchemy.orm import scoped_session 33 | from sqlalchemy.orm import synonym 34 | 35 | from zope.sqlalchemy import ZopeTransactionExtension 36 | 37 | from cryptography.hazmat.primitives.kdf.scrypt import Scrypt 38 | from cryptography.hazmat.backends import default_backend 39 | from .util import oauth2_settings 40 | 41 | from .generators import gen_token 42 | from .generators import gen_client_id 43 | from .generators import gen_client_secret 44 | 45 | DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) 46 | Base = declarative_base() 47 | backend = default_backend() 48 | 49 | 50 | class Oauth2Client(Base): 51 | __tablename__ = 'oauth2_provider_clients' 52 | id = Column(Integer, primary_key=True) 53 | client_id = Column(Unicode(64), unique=True, nullable=False) 54 | _client_secret = Column(Binary(255), nullable=False) 55 | revoked = Column(Boolean, default=False) 56 | revocation_date = Column(DateTime) 57 | _salt = None 58 | 59 | def __init__(self, salt=None): 60 | self._salt = salt 61 | self.client_id = gen_client_id() 62 | self.client_secret = gen_client_secret() 63 | 64 | def new_client_secret(self): 65 | secret = gen_client_secret() 66 | self.client_secret = secret 67 | return secret 68 | 69 | def _get_client_secret(self): 70 | return self._client_secret 71 | 72 | def _set_client_secret(self, client_secret): 73 | if self._salt: 74 | salt = b64decode(self._salt.encode('utf-8')) 75 | else: 76 | try: 77 | if not oauth2_settings('salt'): 78 | raise ValueError( 79 | 'oauth2_provider.salt configuration required.' 80 | ) 81 | salt = b64decode(oauth2_settings('salt').encode('utf-8')) 82 | except AttributeError: 83 | return 84 | 85 | kdf = Scrypt( 86 | salt=salt, 87 | length=64, 88 | n=2 ** 14, 89 | r=8, 90 | p=1, 91 | backend=backend 92 | ) 93 | 94 | try: 95 | client_secret = bytes(client_secret, 'utf-8') 96 | except TypeError: 97 | pass 98 | self._client_secret = kdf.derive(client_secret) 99 | 100 | client_secret = synonym('_client_secret', descriptor=property( 101 | _get_client_secret, _set_client_secret)) 102 | 103 | def revoke(self): 104 | self.revoked = True 105 | self.revocation_date = datetime.utcnow() 106 | 107 | def isRevoked(self): 108 | return self.revoked 109 | 110 | 111 | class Oauth2RedirectUri(Base): 112 | __tablename__ = 'oauth2_provider_redirect_uris' 113 | id = Column(Integer, primary_key=True) 114 | uri = Column(Unicode(256), unique=True, nullable=False) 115 | 116 | client_id = Column(Integer, ForeignKey(Oauth2Client.id)) 117 | client = relationship(Oauth2Client, backref=backref('redirect_uris')) 118 | 119 | def __init__(self, client, uri): 120 | self.client = client 121 | self.uri = uri 122 | 123 | 124 | class Oauth2Code(Base): 125 | __tablename__ = 'oauth2_provider_codes' 126 | id = Column(Integer, primary_key=True) 127 | user_id = Column(Integer, nullable=False) 128 | authcode = Column(Unicode(64), unique=True, nullable=False) 129 | expires_in = Column(Integer, nullable=False, default=10*60) 130 | 131 | revoked = Column(Boolean, default=False) 132 | revocation_date = Column(DateTime) 133 | 134 | creation_date = Column(DateTime, default=datetime.utcnow) 135 | 136 | client_id = Column(Integer, ForeignKey(Oauth2Client.id)) 137 | client = relationship(Oauth2Client, backref=backref('authcode')) 138 | 139 | def __init__(self, client, user_id): 140 | self.client = client 141 | self.user_id = user_id 142 | 143 | self.authcode = gen_token(self.client) 144 | 145 | def revoke(self): 146 | self.revoked = True 147 | self.revocation_date = datetime.utcnow() 148 | 149 | def isRevoked(self): 150 | expiry = time.mktime(self.create_date.timetuple()) + self.expires_in 151 | if datetime.frometimestamp(expiry) < datetime.utcnow(): 152 | self.revoke() 153 | return self.revoked 154 | 155 | 156 | class Oauth2Token(Base): 157 | __tablename__ = 'oauth2_provider_tokens' 158 | id = Column(Integer, primary_key=True) 159 | user_id = Column(Integer, nullable=False) 160 | access_token = Column(Unicode(64), unique=True, nullable=False) 161 | refresh_token = Column(Unicode(64), unique=True, nullable=False) 162 | expires_in = Column(Integer, nullable=False, default=60*60) 163 | 164 | revoked = Column(Boolean, default=False) 165 | revocation_date = Column(DateTime) 166 | 167 | creation_date = Column(DateTime, default=datetime.utcnow) 168 | 169 | client_id = Column(Integer, ForeignKey(Oauth2Client.id)) 170 | client = relationship(Oauth2Client, backref=backref('tokens')) 171 | 172 | def __init__(self, client, user_id): 173 | self.client = client 174 | self.user_id = user_id 175 | 176 | self.access_token = gen_token(self.client) 177 | self.refresh_token = gen_token(self.client) 178 | 179 | def revoke(self): 180 | self.revoked = True 181 | self.revocation_date = datetime.utcnow() 182 | 183 | def isRevoked(self): 184 | expiry = time.mktime(self.creation_date.timetuple()) + self.expires_in 185 | if datetime.fromtimestamp(expiry) < datetime.utcnow(): 186 | self.revoke() 187 | return self.revoked 188 | 189 | def refresh(self): 190 | """ 191 | Generate a new token for this client. 192 | """ 193 | 194 | cls = self.__class__ 195 | self.revoke() 196 | return cls(self.client, self.user_id) 197 | 198 | def asJSON(self, **kwargs): 199 | token = { 200 | 'access_token': self.access_token, 201 | 'refresh_token': self.refresh_token, 202 | 'user_id': self.user_id, 203 | 'expires_in': self.expires_in, 204 | } 205 | kwargs.update(token) 206 | return kwargs 207 | 208 | 209 | def initialize_sql(engine, settings): 210 | DBSession.configure(bind=engine) 211 | Base.metadata.bind = engine 212 | Base.metadata.create_all(engine) 213 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/views.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warranty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import logging 14 | 15 | from base64 import b64decode 16 | 17 | from pyramid.view import view_config 18 | from pyramid.security import NO_PERMISSION_REQUIRED 19 | from pyramid.security import authenticated_userid 20 | from pyramid.security import Authenticated 21 | from pyramid.httpexceptions import HTTPFound 22 | from six.moves.urllib.parse import urlparse 23 | from six.moves.urllib.parse import parse_qsl 24 | from six.moves.urllib.parse import ParseResult 25 | from six.moves.urllib.parse import urlencode 26 | from cryptography.hazmat.primitives.kdf.scrypt import Scrypt 27 | from cryptography.exceptions import InvalidKey 28 | 29 | from .models import DBSession as db 30 | from .models import Oauth2Token 31 | from .models import Oauth2Code 32 | from .models import Oauth2RedirectUri 33 | from .models import Oauth2Client 34 | from .models import backend 35 | from .errors import InvalidToken 36 | from .errors import InvalidClient 37 | from .errors import InvalidRequest 38 | from .errors import UnsupportedGrantType 39 | from .util import oauth2_settings 40 | from .util import getClientCredentials 41 | from .interfaces import IAuthCheck 42 | from .jsonerrors import HTTPBadRequest 43 | from .jsonerrors import HTTPUnauthorized 44 | from .jsonerrors import HTTPMethodNotAllowed 45 | 46 | 47 | def require_https(handler): 48 | """ 49 | This check should be taken care of via the authorization policy, but in 50 | case someone has configured a different policy, check again. HTTPS is 51 | required for all Oauth2 authenticated requests to ensure the security of 52 | client credentials and authorization tokens. 53 | """ 54 | def wrapped(request): 55 | if (request.scheme != 'https' and 56 | oauth2_settings('require_ssl', default=True)): 57 | log.info('rejected request due to unsupported scheme: %s' 58 | % request.scheme) 59 | return HTTPBadRequest(InvalidRequest( 60 | error_description='Oauth2 requires all requests' 61 | ' to be made via HTTPS.')) 62 | return handler(request) 63 | return wrapped 64 | 65 | 66 | log = logging.getLogger('pyramid_oauth2_provider.views') 67 | 68 | @view_config(route_name='oauth2_provider_authorize', renderer='json', 69 | permission=Authenticated) 70 | @require_https 71 | def oauth2_authorize(request): 72 | """ 73 | * In the case of a 'code' authorize request a GET or POST is made 74 | with the following structure. 75 | 76 | GET /authorize?response_type=code&client_id=aoiuer HTTP/1.1 77 | Host: server.example.com 78 | 79 | POST /authorize HTTP/1.1 80 | Host: server.example.com 81 | Content-Type: application/x-www-form-urlencoded 82 | 83 | response_type=code&client_id=aoiuer 84 | 85 | The response_type and client_id are required parameters. A redirect_uri 86 | and state parameters may also be supplied. The redirect_uri will be 87 | validated against the URI's registered for the client. The state is an 88 | opaque value that is simply passed through for security on the client's 89 | end. 90 | 91 | The response to a 'code' request will be a redirect to a registered URI 92 | with the authorization code and optional state values as query 93 | parameters. 94 | 95 | HTTP/1.1 302 Found 96 | Location: https://client.example.com/cb?code=AverTaer&state=efg 97 | 98 | """ 99 | request.client_id = request.params.get('client_id') 100 | 101 | client = db.query(Oauth2Client).filter_by( 102 | client_id=request.client_id).first() 103 | 104 | if not client: 105 | log.info('received invalid client credentials') 106 | return HTTPBadRequest(InvalidRequest( 107 | error_description='Invalid client credentials')) 108 | 109 | redirect_uri = request.params.get('redirect_uri') 110 | redirection_uri = None 111 | if len(client.redirect_uris) == 1 and ( 112 | not redirect_uri or redirect_uri == client.redirect_uris[0]): 113 | redirection_uri = client.redirect_uris[0] 114 | elif len(client.redirect_uris) > 0: 115 | redirection_uri = db.query(Oauth2RedirectUri)\ 116 | .filter_by(client_id=client.id, uri=redirect_uri).first() 117 | 118 | if redirection_uri is None: 119 | return HTTPBadRequest(InvalidRequest( 120 | error_description='Redirection URI validation failed')) 121 | 122 | resp = None 123 | response_type = request.params.get('response_type') 124 | state = request.params.get('state') 125 | if 'code' == response_type: 126 | resp = handle_authcode(request, client, redirection_uri, state) 127 | elif 'token' == response_type: 128 | resp = handle_implicit(request, client, redirection_uri, state) 129 | else: 130 | log.info('received invalid response_type %s') 131 | resp = HTTPBadRequest(InvalidRequest(error_description='Oauth2 unknown ' 132 | 'response_type not supported')) 133 | return resp 134 | 135 | def handle_authcode(request, client, redirection_uri, state=None): 136 | parts = urlparse(redirection_uri.uri) 137 | qparams = dict(parse_qsl(parts.query)) 138 | 139 | user_id = authenticated_userid(request) 140 | auth_code = Oauth2Code(client, user_id) 141 | db.add(auth_code) 142 | db.flush() 143 | 144 | qparams['code'] = auth_code.authcode 145 | if state: 146 | qparams['state'] = state 147 | parts = ParseResult( 148 | parts.scheme, parts.netloc, parts.path, parts.params, 149 | urlencode(qparams), '') 150 | return HTTPFound(location=parts.geturl()) 151 | 152 | def handle_implicit(request, client, redirection_uri, state=None): 153 | return HTTPBadRequest(InvalidRequest(error_description='Oauth2 ' 154 | 'response_type "implicit" not supported')) 155 | 156 | @view_config(route_name='oauth2_provider_token', renderer='json', 157 | permission=NO_PERMISSION_REQUIRED) 158 | @require_https 159 | def oauth2_token(request): 160 | """ 161 | * In the case of an incoming authentication request a POST is made 162 | with the following structure. 163 | 164 | POST /token HTTP/1.1 165 | Host: server.example.com 166 | Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW 167 | Content-Type: application/x-www-form-urlencoded 168 | 169 | grant_type=password&username=johndoe&password=A3ddj3w 170 | 171 | The basic auth header contains the client_id:client_secret base64 172 | encoded for client authentication. 173 | 174 | The username and password are form encoded as part of the body. This 175 | request *must* be made over https. 176 | 177 | The response to this request will be, assuming no error: 178 | 179 | HTTP/1.1 200 OK 180 | Content-Type: application/json;charset=UTF-8 181 | Cache-Control: no-store 182 | Pragma: no-cache 183 | 184 | { 185 | "access_token":"2YotnFZFEjr1zCsicMWpAA", 186 | "token_type":"bearer", 187 | "expires_in":3600, 188 | "refresh_token":"tGzv3JOkF0XG5Qx2TlKW", 189 | "user_id":1234, 190 | } 191 | 192 | * In the case of a token refresh request a POST with the following 193 | structure is required: 194 | 195 | POST /token HTTP/1.1 196 | Host: server.example.com 197 | Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW 198 | Content-Type: application/x-www-form-urlencoded 199 | 200 | grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKW&user_id=1234 201 | 202 | The response will be the same as above with a new access_token and 203 | refresh_token. 204 | """ 205 | 206 | # Make sure this is a POST. 207 | if request.method != 'POST': 208 | log.info('rejected request due to invalid method: %s' % request.method) 209 | return HTTPMethodNotAllowed( 210 | 'This endpoint only supports the POST method.') 211 | 212 | getClientCredentials(request) 213 | 214 | # Make sure we got a client_id and secret through the authorization 215 | # policy. Note that you should only get here if not using the Oauth2 216 | # authorization policy or access was granted through the AuthTKt policy. 217 | if (not hasattr(request, 'client_id') or 218 | not hasattr(request, 'client_secret')): 219 | log.info('did not receive client credentials') 220 | return HTTPUnauthorized('Invalid client credentials') 221 | 222 | client = db.query(Oauth2Client).filter_by( 223 | client_id=request.client_id).first() 224 | 225 | # Again, the authorization policy should catch this, but check again. 226 | if not oauth2_settings('salt'): 227 | raise ValueError('oauth2_provider.salt configuration required.') 228 | salt = b64decode(oauth2_settings('salt').encode('utf-8')) 229 | kdf = Scrypt( 230 | salt=salt, 231 | length=64, 232 | n=2 ** 14, 233 | r=8, 234 | p=1, 235 | backend=backend 236 | ) 237 | 238 | try: 239 | client_secret = request.client_secret 240 | try: 241 | client_secret = bytes(client_secret, 'utf-8') 242 | except TypeError: 243 | client_secret = client_secret.encode('utf-8') 244 | kdf.verify(client_secret, client.client_secret) 245 | bad_secret = False 246 | except (AttributeError, InvalidKey): 247 | bad_secret = True 248 | if not client or bad_secret: 249 | log.info('received invalid client credentials') 250 | return HTTPBadRequest(InvalidRequest( 251 | error_description='Invalid client credentials')) 252 | 253 | # Check for supported grant type. This is a required field of the form 254 | # submission. 255 | resp = None 256 | grant_type = request.POST.get('grant_type') 257 | if grant_type == 'password': 258 | resp = handle_password(request, client) 259 | elif grant_type == 'refresh_token': 260 | resp = handle_refresh_token(request, client) 261 | else: 262 | log.info('invalid grant type: %s' % grant_type) 263 | return HTTPBadRequest(UnsupportedGrantType(error_description='Only ' 264 | 'password and refresh_token grant types are supported by this ' 265 | 'authentication server')) 266 | 267 | add_cache_headers(request) 268 | return resp 269 | 270 | def handle_password(request, client): 271 | if 'username' not in request.POST or 'password' not in request.POST: 272 | log.info('missing username or password') 273 | return HTTPBadRequest(InvalidRequest(error_description='Both username ' 274 | 'and password are required to obtain a password based grant.')) 275 | 276 | auth_check = request.registry.queryUtility(IAuthCheck) 277 | user_id = auth_check().checkauth(request.POST.get('username'), 278 | request.POST.get('password')) 279 | 280 | if not user_id: 281 | log.info('could not validate user credentials') 282 | return HTTPUnauthorized(InvalidClient(error_description='Username and ' 283 | 'password are invalid.')) 284 | 285 | auth_token = Oauth2Token(client, user_id) 286 | db.add(auth_token) 287 | db.flush() 288 | return auth_token.asJSON(token_type='bearer') 289 | 290 | def handle_refresh_token(request, client): 291 | if 'refresh_token' not in request.POST: 292 | log.info('refresh_token field missing') 293 | return HTTPBadRequest(InvalidRequest(error_description='refresh_token ' 294 | 'field required')) 295 | 296 | if 'user_id' not in request.POST: 297 | log.info('user_id field missing') 298 | return HTTPBadRequest(InvalidRequest(error_description='user_id ' 299 | 'field required')) 300 | 301 | auth_token = db.query(Oauth2Token).filter_by( 302 | refresh_token=request.POST.get('refresh_token')).first() 303 | 304 | if not auth_token: 305 | log.info('invalid refresh_token') 306 | return HTTPUnauthorized(InvalidToken(error_description='Provided ' 307 | 'refresh_token is not valid.')) 308 | 309 | if auth_token.client.client_id != client.client_id: 310 | log.info('invalid client_id') 311 | return HTTPBadRequest(InvalidClient(error_description='Client does ' 312 | 'not own this refresh_token.')) 313 | 314 | if str(auth_token.user_id) != request.POST.get('user_id'): 315 | log.info('invalid user_id') 316 | return HTTPBadRequest(InvalidClient(error_description='The given ' 317 | 'user_id does not match the given refresh_token.')) 318 | 319 | new_token = auth_token.refresh() 320 | db.add(new_token) 321 | db.flush() 322 | return new_token.asJSON(token_type='bearer') 323 | 324 | def add_cache_headers(request): 325 | """ 326 | The Oauth2 draft spec requires that all token endpoint traffic be marked 327 | as uncacheable. 328 | """ 329 | 330 | resp = request.response 331 | resp.headerlist.append(('Cache-Control', 'no-store')) 332 | resp.headerlist.append(('Pragma', 'no-cache')) 333 | return request 334 | -------------------------------------------------------------------------------- /pyramid_oauth2_provider/tests.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) Elliot Peele 3 | # 4 | # This program is distributed under the terms of the MIT License as found 5 | # in a file called LICENSE. If it is not present, the license 6 | # is always available at http://www.opensource.org/licenses/mit-license.php. 7 | # 8 | # This program is distributed in the hope that it will be useful, but 9 | # without any warrenty; without even the implied warranty of merchantability 10 | # or fitness for a particular purpose. See the MIT License for full details. 11 | # 12 | 13 | import base64 14 | import unittest 15 | import transaction 16 | from six.moves.urllib.parse import urlparse 17 | from six.moves.urllib.parse import parse_qsl 18 | 19 | from sqlalchemy import create_engine 20 | 21 | from zope.interface import implementer 22 | 23 | from pyramid import testing 24 | from pyramid.response import Response 25 | 26 | from . import jsonerrors 27 | from .views import oauth2_token 28 | from .views import oauth2_authorize 29 | from .models import DBSession 30 | from .models import Oauth2Token 31 | from .models import Oauth2Client 32 | from .models import Oauth2Code 33 | from .models import Oauth2RedirectUri 34 | from .models import initialize_sql 35 | from .interfaces import IAuthCheck 36 | 37 | _auth_value = None 38 | 39 | @implementer(IAuthCheck) 40 | class AuthCheck(object): 41 | def checkauth(self, username, password): 42 | return _auth_value 43 | 44 | _redirect_uri = None 45 | 46 | 47 | class TestCase(unittest.TestCase): 48 | def setUp(self): 49 | # Random salt for tests, don't use for config! 50 | self.config = testing.setUp( 51 | settings = {'oauth2_provider.salt': 'r+H5LT6EvgSSKFMZ2brdzQ=='}) 52 | self.config.registry.registerUtility(AuthCheck, IAuthCheck) 53 | 54 | engine = create_engine('sqlite://') 55 | initialize_sql(engine, self.config) 56 | 57 | self.auth = 1 58 | 59 | self.redirect_uri = 'http://localhost' 60 | 61 | def _get_auth(self): 62 | global _auth_value 63 | return _auth_value 64 | 65 | def _set_auth(self, value): 66 | global _auth_value 67 | _auth_value = value 68 | 69 | auth = property(_get_auth, _set_auth) 70 | 71 | def _get_redirect_uri(self): 72 | global _redirect_uri 73 | return _redirect_uri 74 | 75 | def _set_redirect_uri(self, uri): 76 | global _redirect_uri 77 | _redirect_uri = uri 78 | 79 | redirect_uri = property(_get_redirect_uri, _set_redirect_uri) 80 | 81 | def tearDown(self): 82 | DBSession.remove() 83 | testing.tearDown() 84 | 85 | def getAuthHeader(self, username, password, scheme='Basic'): 86 | encoded = base64.b64encode(('%s:%s' % (username, password)).encode('utf8')) 87 | return {'Authorization': '%s %s' % (scheme, encoded.decode('utf8'))} 88 | 89 | class TestAuthorizeEndpoint(TestCase): 90 | def setUp(self): 91 | TestCase.setUp(self) 92 | self.client = self._create_client() 93 | self.request = self._create_request() 94 | self.config.testing_securitypolicy(self.auth) 95 | 96 | def tearDown(self): 97 | TestCase.tearDown(self) 98 | self.client = None 99 | self.request = None 100 | 101 | def _create_client(self): 102 | with transaction.manager: 103 | client = Oauth2Client() 104 | DBSession.add(client) 105 | client_id = client.client_id 106 | 107 | redirect_uri = Oauth2RedirectUri(client, self.redirect_uri) 108 | DBSession.add(redirect_uri) 109 | 110 | client = DBSession.query(Oauth2Client).filter_by(client_id=client_id).first() 111 | return client 112 | 113 | def _create_request(self): 114 | data = { 115 | 'response_type': 'code', 116 | 'client_id': self.client.client_id 117 | } 118 | 119 | request = testing.DummyRequest(params=data) 120 | request.scheme = 'https' 121 | 122 | return request 123 | 124 | def _create_implicit_request(self): 125 | data = { 126 | 'response_type': 'token', 127 | 'client_id': self.client.client_id 128 | } 129 | 130 | request = testing.DummyRequest(post=data) 131 | request.scheme = 'https' 132 | 133 | return request 134 | 135 | def _process_view(self): 136 | with transaction.manager: 137 | token = oauth2_authorize(self.request) 138 | return token 139 | 140 | def _validate_authcode_response(self, response): 141 | self.assertTrue(isinstance(response, Response)) 142 | self.assertEqual(response.status_int, 302) 143 | 144 | redirect = urlparse(self.redirect_uri) 145 | location = urlparse(response.location) 146 | self.assertEqual(location.scheme, redirect.scheme) 147 | self.assertEqual(location.hostname, redirect.hostname) 148 | self.assertEqual(location.path, redirect.path) 149 | self.assertFalse(location.fragment) 150 | 151 | params = dict(parse_qsl(location.query)) 152 | 153 | self.assertTrue('code' in params) 154 | 155 | dbauthcodes = DBSession.query(Oauth2Code).filter_by( 156 | authcode=params.get('code')).all() 157 | 158 | self.assertTrue(len(dbauthcodes) == 1) 159 | 160 | def testAuthCodeRequest(self): 161 | response = self._process_view() 162 | self._validate_authcode_response(response) 163 | 164 | def testInvalidScheme(self): 165 | self.request.scheme = 'http' 166 | response = self._process_view() 167 | self.assertTrue(isinstance(response, jsonerrors.HTTPBadRequest)) 168 | 169 | def testDisableSchemeCheck(self): 170 | self.request.scheme = 'http' 171 | self.config.get_settings()['oauth2_provider.require_ssl'] = False 172 | response = self._process_view() 173 | self._validate_authcode_response(response) 174 | 175 | def testNoClientCreds(self): 176 | self.request.params.pop('client_id') 177 | response = self._process_view() 178 | self.assertTrue(isinstance(response, jsonerrors.HTTPBadRequest)) 179 | 180 | def testNoResponseType(self): 181 | self.request.params.pop('response_type') 182 | response = self._process_view() 183 | self.assertTrue(isinstance(response, jsonerrors.HTTPBadRequest)) 184 | 185 | def testRedirectUriSupplied(self): 186 | self.request.params['redirect_uri'] = self.redirect_uri 187 | response = self._process_view() 188 | self._validate_authcode_response(response) 189 | 190 | def testMultipleRedirectUrisUnspecified(self): 191 | with transaction.manager: 192 | redirect_uri = Oauth2RedirectUri(self.client, 'https://otherhost.com') 193 | DBSession.add(redirect_uri) 194 | response = self._process_view() 195 | self.assertTrue(isinstance(response, jsonerrors.HTTPBadRequest)) 196 | 197 | def testMultipleRedirectUrisSpecified(self): 198 | with transaction.manager: 199 | redirect_uri = Oauth2RedirectUri(self.client, 'https://otherhost.com') 200 | DBSession.add(redirect_uri) 201 | self.request.params['redirect_uri'] = 'https://otherhost.com' 202 | self.redirect_uri = 'https://otherhost.com' 203 | response = self._process_view() 204 | self._validate_authcode_response(response) 205 | 206 | def testRetainRedirectQueryComponent(self): 207 | uri = 'https://otherhost.com/and/path?some=value' 208 | with transaction.manager: 209 | redirect_uri = Oauth2RedirectUri( 210 | self.client, uri) 211 | DBSession.add(redirect_uri) 212 | self.request.params['redirect_uri'] = uri 213 | self.redirect_uri = uri 214 | response = self._process_view() 215 | self._validate_authcode_response(response) 216 | 217 | parts = urlparse(response.location) 218 | params = dict(parse_qsl(parts.query)) 219 | 220 | self.assertTrue('some' in params) 221 | self.assertEqual(params['some'], 'value') 222 | 223 | def testState(self): 224 | state_value = 'testing' 225 | self.request.params['state'] = state_value 226 | response = self._process_view() 227 | self._validate_authcode_response(response) 228 | parts = urlparse(response.location) 229 | params = dict(parse_qsl(parts.query)) 230 | self.assertTrue('state' in params) 231 | self.assertEqual(state_value, params['state']) 232 | 233 | class TestTokenEndpoint(TestCase): 234 | def setUp(self): 235 | TestCase.setUp(self) 236 | self.client, self.client_secret = self._create_client() 237 | self.request = self._create_request() 238 | 239 | def tearDown(self): 240 | TestCase.tearDown(self) 241 | self.client = None 242 | self.request = None 243 | 244 | def _create_client(self): 245 | with transaction.manager: 246 | client = Oauth2Client() 247 | client_secret = client.new_client_secret() 248 | DBSession.add(client) 249 | client_id = client.client_id 250 | 251 | client = DBSession.query(Oauth2Client).filter_by( 252 | client_id=client_id).first() 253 | return client, client_secret 254 | 255 | def _create_request(self): 256 | headers = self.getAuthHeader( 257 | self.client.client_id, 258 | self.client_secret) 259 | 260 | data = { 261 | 'grant_type': 'password', 262 | 'username': 'john', 263 | 'password': 'foo', 264 | } 265 | 266 | request = testing.DummyRequest(post=data, headers=headers) 267 | request.scheme = 'https' 268 | 269 | return request 270 | 271 | def _create_refresh_token_request(self, refresh_token, user_id): 272 | headers = self.getAuthHeader( 273 | self.client.client_id, 274 | self.client_secret) 275 | 276 | data = { 277 | 'grant_type': 'refresh_token', 278 | 'refresh_token': refresh_token, 279 | 'user_id': str(user_id), 280 | } 281 | 282 | request = testing.DummyRequest(post=data, headers=headers) 283 | request.scheme = 'https' 284 | 285 | return request 286 | 287 | def _process_view(self): 288 | with transaction.manager: 289 | token = oauth2_token(self.request) 290 | return token 291 | 292 | def _validate_token(self, token): 293 | self.assertTrue(isinstance(token, dict)) 294 | self.assertEqual(token.get('user_id'), self.auth) 295 | self.assertEqual(token.get('expires_in'), 3600) 296 | self.assertEqual(token.get('token_type'), 'bearer') 297 | self.assertEqual(len(token.get('access_token')), 64) 298 | self.assertEqual(len(token.get('refresh_token')), 64) 299 | self.assertEqual(len(token), 5) 300 | 301 | dbtoken = DBSession.query(Oauth2Token).filter_by( 302 | access_token=token.get('access_token')).first() 303 | 304 | self.assertEqual(dbtoken.user_id, token.get('user_id')) 305 | self.assertEqual(dbtoken.expires_in, token.get('expires_in')) 306 | self.assertEqual(dbtoken.access_token, token.get('access_token')) 307 | self.assertEqual(dbtoken.refresh_token, token.get('refresh_token')) 308 | 309 | def testTokenRequest(self): 310 | self.auth = 500 311 | token = self._process_view() 312 | self._validate_token(token) 313 | 314 | def testInvalidMethod(self): 315 | self.request.method = 'GET' 316 | token = self._process_view() 317 | self.assertTrue(isinstance(token, jsonerrors.HTTPMethodNotAllowed)) 318 | 319 | def testInvalidScheme(self): 320 | self.request.scheme = 'http' 321 | token = self._process_view() 322 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 323 | 324 | def testDisableSchemeCheck(self): 325 | self.request.scheme = 'http' 326 | self.config.get_settings()['oauth2_provider.require_ssl'] = False 327 | token = self._process_view() 328 | self._validate_token(token) 329 | 330 | def testNoClientCreds(self): 331 | self.request.headers = {} 332 | token = self._process_view() 333 | self.assertTrue(isinstance(token, jsonerrors.HTTPUnauthorized)) 334 | 335 | def testInvalidClientCreds(self): 336 | self.request.headers = self.getAuthHeader( 337 | self.client.client_id, 'abcde') 338 | token = self._process_view() 339 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 340 | 341 | def testInvalidGrantType(self): 342 | self.request.POST['grant_type'] = 'foo' 343 | token = self._process_view() 344 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 345 | 346 | def testCacheHeaders(self): 347 | self._process_view() 348 | self.assertEqual( 349 | self.request.response.headers.get('Cache-Control'), 'no-store') 350 | self.assertEqual( 351 | self.request.response.headers.get('Pragma'), 'no-cache') 352 | 353 | def testMissingUsername(self): 354 | self.request.POST.pop('username') 355 | token = self._process_view() 356 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 357 | 358 | def testMissingPassword(self): 359 | self.request.POST.pop('password') 360 | token = self._process_view() 361 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 362 | 363 | def testFailedPassword(self): 364 | self.auth = False 365 | token = self._process_view() 366 | self.assertTrue(isinstance(token, jsonerrors.HTTPUnauthorized)) 367 | 368 | def testRefreshToken(self): 369 | token = self._process_view() 370 | self._validate_token(token) 371 | self.request = self._create_refresh_token_request( 372 | token.get('refresh_token'), token.get('user_id')) 373 | token = self._process_view() 374 | self._validate_token(token) 375 | 376 | def testMissingRefreshToken(self): 377 | token = self._process_view() 378 | self._validate_token(token) 379 | self.request = self._create_refresh_token_request( 380 | token.get('refresh_token'), token.get('user_id')) 381 | self.request.POST.pop('refresh_token') 382 | token = self._process_view() 383 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 384 | 385 | def testMissingUserId(self): 386 | token = self._process_view() 387 | self._validate_token(token) 388 | self.request = self._create_refresh_token_request( 389 | token.get('refresh_token'), token.get('user_id')) 390 | self.request.POST.pop('user_id') 391 | token = self._process_view() 392 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 393 | 394 | def testInvalidRefreshToken(self): 395 | token = self._process_view() 396 | self._validate_token(token) 397 | self.request = self._create_refresh_token_request( 398 | 'abcd', token.get('user_id')) 399 | token = self._process_view() 400 | self.assertTrue(isinstance(token, jsonerrors.HTTPUnauthorized)) 401 | 402 | def testRefreshInvalidClientId(self): 403 | token = self._process_view() 404 | self._validate_token(token) 405 | self.request = self._create_refresh_token_request( 406 | token.get('refresh_token'), token.get('user_id')) 407 | self.request.headers = self.getAuthHeader( 408 | '1234', self.client_secret) 409 | token = self._process_view() 410 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 411 | 412 | def testUserIdMissmatch(self): 413 | token = self._process_view() 414 | self._validate_token(token) 415 | self.request = self._create_refresh_token_request( 416 | token.get('refresh_token'), '2') 417 | token = self._process_view() 418 | self.assertTrue(isinstance(token, jsonerrors.HTTPBadRequest)) 419 | 420 | def testRevokedAccessTokenRefresh(self): 421 | token = self._process_view() 422 | self._validate_token(token) 423 | 424 | dbtoken = DBSession.query(Oauth2Token).filter_by( 425 | access_token=token.get('access_token')).first() 426 | dbtoken.revoke() 427 | 428 | self.request = self._create_refresh_token_request( 429 | token.get('refresh_token'), token.get('user_id')) 430 | token = self._process_view() 431 | self._validate_token(token) 432 | 433 | def testTimeRevokeAccessToken(self): 434 | token = self._process_view() 435 | self._validate_token(token) 436 | 437 | dbtoken = DBSession.query(Oauth2Token).filter_by( 438 | access_token=token.get('access_token')).first() 439 | dbtoken.expires_in = 0 440 | 441 | self.assertEqual(dbtoken.isRevoked(), True) 442 | 443 | def testTimeRevokeAccessToken2(self): 444 | token = self._process_view() 445 | self._validate_token(token) 446 | 447 | dbtoken = DBSession.query(Oauth2Token).filter_by( 448 | access_token=token.get('access_token')).first() 449 | dbtoken.expires_in = 10 450 | 451 | self.assertEqual(dbtoken.isRevoked(), False) 452 | --------------------------------------------------------------------------------