From 2cc3eb256bbfc08411bd69f5c5f4887792b542e8 Mon Sep 17 00:00:00 2001
From: Matt Jia <mjia@redhat.com>
Date: Apr 06 2017 23:32:22 +0000
Subject: support OIDC authentication


---

diff --git a/conf/client_secrets.json b/conf/client_secrets.json
new file mode 100644
index 0000000..91985b0
--- /dev/null
+++ b/conf/client_secrets.json
@@ -0,0 +1,11 @@
+{
+    "web": {
+        "redirect_uris": ["http://localhost:5005/"],
+        "token_uri": "https://iddev.fedorainfracloud.org/openidc/Token",
+        "auth_uri": "https://iddev.fedorainfracloud.org/openidc/Authorization",
+        "client_id": "D-e69a1ac7-30fa-4d18-9001-7468c4f34c3c",
+        "client_secret": "qgz8Bzjg6nO7JWCXoB0o8L49KfI5atLF",
+        "userinfo_uri": "https://iddev.fedorainfracloud.org/openidc/UserInfo",
+        "token_introspection_uri": "https://iddev.fedorainfracloud.org/openidc/TokenInfo"
+    }
+}
diff --git a/requirements.txt b/requirements.txt
index a201a83..d5c9310 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,7 +7,9 @@ Flask-RESTful
 Flask-SQLAlchemy
 SQLAlchemy
 kerberos >= 1.1.1
+flask-oidc
 
+# packages for the unit tests
 pytest >= 2.4.2
 mock
 
diff --git a/tests/client_secrets.json b/tests/client_secrets.json
new file mode 100644
index 0000000..9e72bf7
--- /dev/null
+++ b/tests/client_secrets.json
@@ -0,0 +1,11 @@
+{
+    "web": {
+        "redirect_uris": ["http://localhost:5005/"],
+        "token_uri": "https://iddev.fedorainfracloud.org/openidc/Token",
+        "auth_uri": "https://iddev.fedorainfracloud.org/openidc/Authorization",
+        "client_id": "randomid",
+        "client_secret": "nosecret",
+        "userinfo_uri": "https://iddev.fedorainfracloud.org/openidc/UserInfo",
+        "token_introspection_uri": "https://iddev.fedorainfracloud.org/openidc/TokenInfo"
+    }
+}
diff --git a/tests/conftest.py b/tests/conftest.py
index c08336e..59a2529 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,7 +11,6 @@
 #
 
 import pytest
-
 from waiverdb.app import create_app, init_db
 
 
@@ -59,3 +58,8 @@ def client(app):
     """
     with app.test_client() as client:
         yield client
+
+
+@pytest.fixture()
+def enable_kerberos(app, monkeypatch):
+    monkeypatch.setitem(app.config, 'AUTH_METHOD', 'Kerberos')
diff --git a/tests/test_app.py b/tests/test_app.py
index 3055c3a..aba4648 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -23,10 +23,12 @@ from flask_sqlalchemy import SignallingSession
 
 class NoZmqConfig(config.Config):
     ZEROMQ_PUBLISH = False
+    AUTH_METHOD = None
 
 
 class ZmqConfig(config.Config):
     ZEROMQ_PUBLISH = True
+    AUTH_METHOD = None
 
 
 @mock.patch('waiverdb.app.event.listen')
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 7f534af..cbc5963 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -15,13 +15,20 @@ import mock
 import json
 from werkzeug.exceptions import Unauthorized
 import waiverdb.auth
+import flask_oidc
 
 
+@pytest.mark.usefixtures('enable_kerberos')
 class TestKerberosAuthentication(object):
 
     def test_keytab_file_is_not_set_should_raise_error(self):
         with pytest.raises(Unauthorized):
             request = mock.MagicMock()
+            headers = {'Authorization': "babablaba"}
+            request.headers.return_value = mock.MagicMock(spec_set=dict)
+            request.headers.__getitem__.side_effect = headers.__getitem__
+            request.headers.__setitem__.side_effect = headers.__setitem__
+            request.headers.__contains__.side_effect = headers.__contains__
             waiverdb.auth.get_user(request)
 
     def test_unauthorized(self, client, monkeypatch):
@@ -52,3 +59,43 @@ class TestKerberosAuthentication(object):
         assert r.headers.get('WWW-Authenticate') == 'negotiate STOKEN'
         res_data = json.loads(r.data.decode('utf-8'))
         assert res_data['username'] == 'foo'
+
+
+class TestOIDCAuthentication(object):
+
+    def test_get_user_without_token(self, session):
+        with pytest.raises(Unauthorized) as excinfo:
+            request = mock.MagicMock()
+            waiverdb.auth.get_user(request)
+        assert "No 'Authorization' header found" in str(excinfo.value)
+
+    @mock.patch.object(flask_oidc.OpenIDConnect, '_get_token_info')
+    def test_get_user_with_invalid_token(self, mocked_get_token, session):
+        # http://vsbattles.wikia.com/wiki/Son_Goku
+        name = 'Son Goku'
+        mocked_get_token.return_value = {'active': False, 'username': name,
+                                         'scope': 'openid waiverdb_scope'}
+        headers = {'Authorization': 'Bearer invalid'}
+        request = mock.MagicMock()
+        request.headers.return_value = mock.MagicMock(spec_set=dict)
+        request.headers.__getitem__.side_effect = headers.__getitem__
+        request.headers.__setitem__.side_effect = headers.__setitem__
+        request.headers.__contains__.side_effect = headers.__contains__
+        with pytest.raises(Unauthorized) as excinfo:
+            waiverdb.auth.get_user(request)
+        assert 'Token required but invalid' in str(excinfo.value)
+
+    @mock.patch.object(flask_oidc.OpenIDConnect, '_get_token_info')
+    def test_get_user_good(self, mocked_get_token, session):
+        # http://vsbattles.wikia.com/wiki/Son_Goku
+        name = 'Son Goku'
+        mocked_get_token.return_value = {'active': True, 'username': name,
+                                         'scope': 'openid waiverdb_scope'}
+        headers = {'Authorization': 'Bearer foobar'}
+        request = mock.MagicMock()
+        request.headers.return_value = mock.MagicMock(spec_set=dict)
+        request.headers.__getitem__.side_effect = headers.__getitem__
+        request.headers.__setitem__.side_effect = headers.__setitem__
+        request.headers.__contains__.side_effect = headers.__contains__
+        user, header = waiverdb.auth.get_user(request)
+        assert user == name
diff --git a/waiverdb/app.py b/waiverdb/app.py
index 3e31b45..d740b84 100644
--- a/waiverdb/app.py
+++ b/waiverdb/app.py
@@ -18,6 +18,7 @@ from waiverdb.events import fedmsg_new_waiver
 from waiverdb.logger import init_logging
 from waiverdb.api_v1 import api_v1
 from waiverdb.models import db
+from flask_oidc import OpenIDConnect
 
 
 def load_default_config(app):
@@ -48,6 +49,8 @@ def create_app(config_obj=None):
         raise Warning("You need to change the app.secret_key value for production")
     if app.config['SHOW_DB_URI']:
         app.logger.debug('using DBURI: %s' % app.config['SQLALCHEMY_DATABASE_URI'])
+    if app.config['AUTH_METHOD'] == 'OIDC':
+        app.oidc = OpenIDConnect(app)
     # initialize db
     db.init_app(app)
     # initialize logging
diff --git a/waiverdb/auth.py b/waiverdb/auth.py
index 8f45935..cb796fa 100644
--- a/waiverdb/auth.py
+++ b/waiverdb/auth.py
@@ -9,9 +9,10 @@
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 # GNU General Public License for more details.
 
+
 import os
 import kerberos
-from flask import current_app, Response
+from flask import current_app, Response, g
 # Starting with Flask 0.9, the _app_ctx_stack is the correct one,
 # before that we need to use the _request_ctx_stack.
 try:
@@ -93,12 +94,28 @@ class KerberosAuthenticate(object):
 def get_user(request):
     user = None
     headers = dict()
-    if 'KRB5_KTNAME' in os.environ:
-        header = request.headers.get("Authorization")
-        if not header:
+    if current_app.config['AUTH_METHOD'] == 'OIDC':
+        if 'Authorization' not in request.headers:
+            raise Unauthorized("No 'Authorization' header found.")
+        token = request.headers.get("Authorization").strip()
+        prefix = 'Bearer '
+        if not token.startswith(prefix):
+            raise Unauthorized('Authorization headers must start with %r' % prefix)
+        token = token[len(prefix):].strip()
+        required_scopes = [
+            'openid',
+            current_app.config['OIDC_REQUIRED_SCOPE'],
+        ]
+        validity = current_app.oidc.validate_token(token, required_scopes)
+        if validity is not True:
+            raise Unauthorized(validity)
+        user = g.oidc_token_info['username']
+    elif current_app.config['AUTH_METHOD'] == 'Kerberos':
+        if 'Authorization' not in request.headers:
             response = Response('Unauthorized', 401, {'WWW-Authenticate': 'Negotiate'})
             raise Unauthorized(response=response)
-        token = ''.join(header.split()[1:])
+        header = request.headers.get("Authorization")
+        token = ''.join(header.strip().split()[1:])
         user, kerberos_token = KerberosAuthenticate().process_request(token)
         # remove realm
         user = user.split("@")[0]
diff --git a/waiverdb/config.py b/waiverdb/config.py
index dd03a3c..421d6cf 100644
--- a/waiverdb/config.py
+++ b/waiverdb/config.py
@@ -9,6 +9,8 @@
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 # GNU General Public License for more details.
 
+import os
+
 
 class Config(object):
     """
@@ -29,6 +31,7 @@ class Config(object):
     # need to explicitly turn this off
     # https://github.com/flask-restful/flask-restful/issues/449
     ERROR_404_HELP = False
+    AUTH_METHOD = 'OIDC'  # Specify OIDC or Kerberos for authentication
     # Change it if the Kerberos service is not running on which the waiverdb is run.
     KERBEROS_HTTP_HOST = None
     ZEROMQ_PUBLISH = True
@@ -44,9 +47,22 @@ class DevelopmentConfig(Config):
     TRAP_BAD_REQUEST_ERRORS = True
     SQLALCHEMY_DATABASE_URI = 'sqlite:////var/tmp/waiverdb_db.sqlite'
     SHOW_DB_URI = True
+    # The location of the client_secrets.json file used for API authentication
+    OIDC_CLIENT_SECRETS = os.path.join(
+        os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'conf',
+        'client_secrets.json'
+    )
+    OIDC_REQUIRED_SCOPE = 'https://waiverdb.fedoraproject.org/oidc/create-waiver'
+    OIDC_RESOURCE_SERVER_ONLY = True
 
 
 class TestingConfig(Config):
     SQLALCHEMY_TRACK_MODIFICATIONS = True
     TRAP_BAD_REQUEST_ERRORS = True
     TESTING = True
+    OIDC_CLIENT_SECRETS = os.path.join(
+        os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'tests',
+        'client_secrets.json'
+    )
+    OIDC_REQUIRED_SCOPE = 'waiverdb_scope'
+    OIDC_RESOURCE_SERVER_ONLY = True

