├── .gitignore ├── LICENSE ├── MAINTAINERS ├── MANIFEST.in ├── README.rst ├── oauth2_proxy ├── __init__.py └── app.py ├── release.sh ├── requirements.txt ├── setup.py ├── tests └── test_dummy.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | client.json 2 | *.egg* 3 | build/ 4 | dist/ 5 | .coverage 6 | .cache 7 | __pycache__ 8 | htmlcov/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Zalando SE 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Henning Jacobs 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt include *.rst recursive-include oauth2_proxy *.py -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | OAuth2 Proxy 3 | ============ 4 | 5 | .. image:: https://img.shields.io/pypi/dw/oauth2-proxy.svg 6 | :target: https://pypi.python.org/pypi/oauth2-proxy/ 7 | :alt: PyPI Downloads 8 | 9 | .. image:: https://img.shields.io/pypi/v/oauth2-proxy.svg 10 | :target: https://pypi.python.org/pypi/oauth2-proxy/ 11 | :alt: Latest PyPI version 12 | 13 | .. image:: https://img.shields.io/pypi/l/oauth2-proxy.svg 14 | :target: https://pypi.python.org/pypi/oauth2-proxy/ 15 | :alt: License 16 | 17 | Flask application to serve static files to authenticated users (via OAuth 2 authorization flow). 18 | 19 | .. code-block:: bash 20 | 21 | $ sudo pip3 install -r requirements.txt 22 | $ python3 -m oauth2_proxy.app 23 | 24 | Environment Variables 25 | ====================== 26 | 27 | The following environment variables can be used for configuration: 28 | 29 | ``APP_DEBUG`` 30 | Enable debug output via HTTP by setting this property to ``true``. Do not set this flag in production. 31 | ``APP_ROOT_DIR`` 32 | Directory to serve static files from. 33 | ``APP_SECRET_KEY`` 34 | Random secret key to sign the session cookie. 35 | ``APP_URL`` 36 | Base URL of the application (needed for OAuth 2 redirect). 37 | ``CREDENTIALS_DIR`` 38 | Directory containing client.json 39 | 40 | -------------------------------------------------------------------------------- /oauth2_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.5' 2 | -------------------------------------------------------------------------------- /oauth2_proxy/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import logging 5 | import os 6 | import requests 7 | import flask 8 | from flask import Flask, redirect, url_for, session, request, send_from_directory 9 | from flask_oauthlib.client import OAuth, OAuthRemoteApp 10 | from urllib.parse import urlparse 11 | 12 | logging.basicConfig(level=logging.DEBUG) 13 | logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.INFO) 14 | 15 | sess = requests.Session() 16 | adapter = requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100) 17 | sess.mount('http://', adapter) 18 | sess.mount('https://', adapter) 19 | 20 | app = Flask(__name__) 21 | app.debug = os.getenv('APP_DEBUG') == 'true' 22 | app.secret_key = os.getenv('APP_SECRET_KEY', 'development') 23 | oauth = OAuth(app) 24 | 25 | 26 | class OAuthRemoteAppWithRefresh(OAuthRemoteApp): 27 | '''Same as flask_oauthlib.client.OAuthRemoteApp, but always loads client credentials from file.''' 28 | 29 | def __init__(self, oauth, name, **kwargs): 30 | # constructor expects some values, so make it happy.. 31 | kwargs['consumer_key'] = 'not-needed-here' 32 | kwargs['consumer_secret'] = 'not-needed-here' 33 | OAuthRemoteApp.__init__(self, oauth, name, **kwargs) 34 | 35 | def refresh_credentials(self): 36 | with open(os.path.join(os.getenv('CREDENTIALS_DIR', ''), 'client.json')) as fd: 37 | client_credentials = json.load(fd) 38 | self._consumer_key = client_credentials['client_id'] 39 | self._consumer_secret = client_credentials['client_secret'] 40 | 41 | @property 42 | def consumer_key(self): 43 | self.refresh_credentials() 44 | return self._consumer_key 45 | 46 | @property 47 | def consumer_secrect(self): 48 | self.refresh_credentials() 49 | return self._consumer_secret 50 | 51 | 52 | auth = OAuthRemoteAppWithRefresh( 53 | oauth, 54 | 'auth', 55 | request_token_params={'scope': 'uid'}, 56 | base_url='https://auth.zalando.com/', 57 | request_token_url=None, 58 | access_token_method='POST', 59 | access_token_url='https://auth.zalando.com/oauth2/access_token?realm=/employees', 60 | authorize_url='https://auth.zalando.com/oauth2/authorize?realm=/employees' 61 | ) 62 | oauth.remote_apps['auth'] = auth 63 | 64 | UPSTREAMS = list(filter(None, os.getenv('APP_UPSTREAM', '').split(','))) 65 | 66 | 67 | @app.route('/', defaults={'path': ''}) 68 | @app.route('/') 69 | def index(path): 70 | if 'auth_token' in session: 71 | if UPSTREAMS: 72 | abs_path = '/{}'.format(path.strip('/')) 73 | for url in UPSTREAMS: 74 | o = urlparse(url) 75 | if abs_path.startswith(o.path): 76 | parts = flask.request.url.split('/', 3) 77 | path_query = parts[-1] 78 | upstream_url = '{scheme}://{netloc}/{path}'.format(scheme=o.scheme, netloc=o.netloc, 79 | path=path_query) 80 | upstream_response = sess.get(upstream_url) 81 | headers = {} 82 | for key, val in upstream_response.headers.items(): 83 | if key in set(['Content-Type']): 84 | headers[key] = val 85 | response = flask.Response(upstream_response.content, upstream_response.status_code, headers) 86 | return response 87 | else: 88 | # serve static files 89 | if not path: 90 | path = 'index.html' 91 | return send_from_directory(os.getenv('APP_ROOT_DIR', './'), path) 92 | return redirect(url_for('login')) 93 | 94 | 95 | @app.route('/health') 96 | def health(): 97 | return 'OK' 98 | 99 | 100 | @app.route('/login') 101 | def login(): 102 | return auth.authorize(callback=os.getenv('APP_URL', '').rstrip('/') + '/login/authorized') 103 | 104 | 105 | @app.route('/logout') 106 | def logout(): 107 | session.pop('auth_token', None) 108 | return redirect(url_for('index')) 109 | 110 | 111 | @app.route('/login/authorized') 112 | def authorized(): 113 | resp = auth.authorized_response() 114 | if resp is None: 115 | return 'Access denied: reason=%s error=%s' % ( 116 | request.args['error'], 117 | request.args['error_description'] 118 | ) 119 | print(resp) 120 | if not isinstance(resp, dict): 121 | return 'Invalid auth response' 122 | session['auth_token'] = (resp['access_token'], '') 123 | return redirect(url_for('index')) 124 | 125 | 126 | @auth.tokengetter 127 | def get_auth_oauth_token(): 128 | return session.get('auth_token') 129 | 130 | 131 | # WSGI application 132 | application = app 133 | 134 | if __name__ == '__main__': 135 | # development mode: run Flask dev server 136 | app.run() 137 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ $# -ne 1 ]; then 4 | >&2 echo "usage: $0 " 5 | exit 1 6 | fi 7 | 8 | set -xe 9 | 10 | python3 --version 11 | git --version 12 | 13 | version=$1 14 | 15 | sed -i "s/__version__ = .*/__version__ = '${version}'/" */__init__.py 16 | 17 | # Do not tag/push on Go CD 18 | if [ -z "$GO_PIPELINE_LABEL" ]; then 19 | python3 setup.py clean 20 | python3 setup.py test 21 | python3 setup.py flake8 22 | 23 | git add */__init__.py 24 | 25 | git commit -m "Bumped version to $version" 26 | git push 27 | fi 28 | 29 | python3 setup.py sdist bdist_wheel upload 30 | 31 | if [ -z "$GO_PIPELINE_LABEL" ]; then 32 | git tag ${version} 33 | git push --tags 34 | fi 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | Flask-OAuthlib 3 | requests 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import inspect 5 | import os 6 | import sys 7 | 8 | from setuptools import setup, find_packages 9 | from setuptools.command.test import test as TestCommand 10 | 11 | 12 | def read_version(package): 13 | with open(os.path.join(package, '__init__.py'), 'r') as fd: 14 | for line in fd: 15 | if line.startswith('__version__ = '): 16 | return line.split()[-1].strip().strip("'") 17 | 18 | version = read_version('oauth2_proxy') 19 | 20 | __location__ = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe()))) 21 | 22 | 23 | def get_install_requirements(path): 24 | content = open(os.path.join(__location__, path)).read() 25 | return [req for req in content.split('\\n') if req != ''] 26 | 27 | 28 | class PyTest(TestCommand): 29 | 30 | user_options = [('cov-html=', None, 'Generate junit html report')] 31 | 32 | def initialize_options(self): 33 | TestCommand.initialize_options(self) 34 | self.cov = None 35 | self.pytest_args = ['--cov', 'oauth2_proxy', '--cov-report', 'term-missing'] 36 | self.cov_html = False 37 | 38 | def finalize_options(self): 39 | TestCommand.finalize_options(self) 40 | if self.cov_html: 41 | self.pytest_args.extend(['--cov-report', 'html']) 42 | 43 | def run_tests(self): 44 | import pytest 45 | 46 | errno = pytest.main(self.pytest_args) 47 | sys.exit(errno) 48 | 49 | 50 | setup( 51 | name='oauth2-proxy', 52 | packages=find_packages(), 53 | version=version, 54 | description='OAuth2 proxy with authorization/redirect flow', 55 | long_description=open('README.rst').read(), 56 | author='Zalando SE', 57 | url='https://github.com/zalando-stups/oauth2-proxy', 58 | keywords='oauth flask proxy serve', 59 | license='Apache License Version 2.0', 60 | setup_requires=['flake8'], 61 | install_requires=get_install_requirements('requirements.txt'), 62 | tests_require=['pytest-cov', 'pytest', 'mock'], 63 | cmdclass={'test': PyTest}, 64 | test_suite='tests', 65 | classifiers=[ 66 | 'Programming Language :: Python', 67 | 'Programming Language :: Python :: 3.4', 68 | 'Development Status :: 4 - Beta', 69 | 'Intended Audience :: Developers', 70 | 'Operating System :: OS Independent', 71 | ] 72 | ) 73 | -------------------------------------------------------------------------------- /tests/test_dummy.py: -------------------------------------------------------------------------------- 1 | 2 | import oauth2_proxy 3 | 4 | def test_dummy(): 5 | assert True 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | --------------------------------------------------------------------------------