Repository: zamzterz/Flask-pyoidc

Issue:
Logout with Blueprints
I have a Flask Application Factory and I use blueprints to structure my Application. The login works fine, but I have problems implementing the logout. 

`@main.route('/logout_user')
@auth.oidc_logout
def logout_function():
    return "You've been successfully logged out!"`

When i call /logout_user i get this error: 
werkzeug.routing.BuildError: Could not build url for endpoint 'logout_function'. Did you mean 'main.logout_function' instead?

Theoretically I should pass 'main.logout_function' somehow to 'oidc_logout' so it knows which function it should use. But I don't have a clue on how to do that. Any idea on how to solve this problem is greatly appreciated.

Thanks

Discussion:

Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 71018a6..d0c5e9e 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 2.0.0
+current_version = 2.2.0
 commit = True
 tag = True
 
diff --git a/.gitignore b/.gitignore
index a6dbce3..4cc1ff1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ dist/
 .coverage
 coverage.xml
 .pytest_cache
+.venv/
diff --git a/.travis.yml b/.travis.yml
index 1de32b1..f7cee8c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,6 +4,8 @@ sudo: false
 
 matrix:
   include:
+    - python: 3.7
+      env: TOXENV=py37
     - python: 3.6
       env: TOXENV=py36
     - python: 3.5
diff --git a/README.md b/README.md
index d5c80f3..da490b4 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,9 @@
 
 This Flask extension provides simple OpenID Connect authentication, backed by [pyoidc](https://github.com/rohe/pyoidc).
 
-*Currently only ["Authorization Code Flow"](http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) is supported.*
+["Authorization Code Flow"](http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth), as well as
+["Implicit Flow"](https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth) and 
+["Hybrid Flow"](https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth), is supported.
 
 ## Example
 
@@ -64,6 +66,8 @@ config = ProviderConfiguration([provider configuration], client_metadata=client_
 
 **Note: The redirect URIs registered with the provider MUST include `<application_url>/redirect_uri`,
 where `<application_url>` is the URL of the Flask application.**
+To configure this extension to use a different endpoint, set the
+[`OIDC_REDIRECT_ENDPOINT` configuration parameter](#flask-configuration).
 
 #### Dynamic client registration
 
@@ -89,6 +93,8 @@ You may also configure the way the user sessions created by this extension are h
 
 * `OIDC_SESSION_PERMANENT`: If set to `True` (which is the default) the user session will be kept until the configured
   session lifetime (see below). If set to `False` the session will be deleted when the user closes the browser.
+* `OIDC_REDIRECT_ENDPOINT`: Set the endpoint used as redirect_uri to receive authentication responses. Defaults to
+  `redirect_uri`, meaning the URL `<application_url>/redirect_uri` needs to be registered with the provider(s).
 * `PERMANENT_SESSION_LIFETIME`: Control how long a user session is valid, see
   [Flask documentation](http://flask.pocoo.org/docs/1.0/config/#PERMANENT_SESSION_LIFETIME) for more information.
 
@@ -169,6 +175,14 @@ def logout():
     return 'You\'ve been successfully logged out!'
 ```
 
+If the logout view is mounted under a custom endpoint (other than the default, which is 
+[the name of the view function](http://flask.pocoo.org/docs/1.0/api/#flask.Flask.route)), or if using Blueprints, you
+must specify the full URL in the Flask-pyoidc configuration using `post_logout_redirect_uris`:
+```python
+ClientMetadata(..., post_logout_redirect_uris=['https://example.com/post_logout']) # if using static client registration
+ClientRegistrationInfo(..., post_logout_redirect_uris=['https://example.com/post_logout']) # if using dynamic client registration 
+```
+
 This extension also supports [RP-Initiated Logout](http://openid.net/specs/openid-connect-session-1_0.html#RPLogout),
 if the provider allows it. Make sure the `end_session_endpoint` is defined in the provider metadata to enable notifying
 the provider when the user logs out. 
diff --git a/setup.py b/setup.py
index 3996dc2..9de8bd4 100644
--- a/setup.py
+++ b/setup.py
@@ -1,19 +1,24 @@
-from setuptools import setup, find_packages
+from setuptools import setup
+
+with open('README.md') as f:
+    long_description = f.read()
 
 setup(
-        name='Flask-pyoidc',
-        version='2.0.0',
-        packages=['flask_pyoidc'],
-        package_dir={'': 'src'},
-        url='https://github.com/zamzterz/flask-pyoidc',
-        license='Apache 2.0',
-        author='Samuel Gulliksson',
-        author_email='samuel.gulliksson@gmail.com',
-        description='Flask extension for OpenID Connect authentication.',
-        install_requires=[
-            'oic==0.12',
-            'Flask',
-            'requests'
-        ],
-        package_data={'flask_pyoidc': ['files/parse_fragment.html']},
+    name='Flask-pyoidc',
+    version='2.2.0',
+    packages=['flask_pyoidc'],
+    package_dir={'': 'src'},
+    url='https://github.com/zamzterz/flask-pyoidc',
+    license='Apache 2.0',
+    author='Samuel Gulliksson',
+    author_email='samuel.gulliksson@gmail.com',
+    description='Flask extension for OpenID Connect authentication.',
+    install_requires=[
+        'oic==0.12',
+        'Flask',
+        'requests'
+    ],
+    package_data={'flask_pyoidc': ['files/parse_fragment.html']},
+    long_description=long_description,
+    long_description_content_type='text/markdown',
 )
diff --git a/src/flask_pyoidc/flask_pyoidc.py b/src/flask_pyoidc/flask_pyoidc.py
index 79816d2..3b4b643 100644
--- a/src/flask_pyoidc/flask_pyoidc.py
+++ b/src/flask_pyoidc/flask_pyoidc.py
@@ -36,13 +36,12 @@ try:
 except ImportError:
     from urlparse import parse_qsl
 
+
 class OIDCAuthentication(object):
     """
     OIDCAuthentication object for Flask extension.
     """
 
-    REDIRECT_URI_ENDPOINT = 'redirect_uri'
-
     def __init__(self, provider_configurations, app=None):
         """
         Args:
@@ -55,35 +54,48 @@ class OIDCAuthentication(object):
         self.clients = None
         self._logout_view = None
         self._error_view = None
+        self._redirect_uri_endpoint = None
 
         if app:
             self.init_app(app)
 
     def init_app(self, app):
+        self._redirect_uri_endpoint = app.config.get('OIDC_REDIRECT_ENDPOINT', 'redirect_uri').lstrip('/')
+
         # setup redirect_uri as a flask route
-        app.add_url_rule('/redirect_uri',
-                         self.REDIRECT_URI_ENDPOINT,
+        app.add_url_rule('/' + self._redirect_uri_endpoint,
+                         self._redirect_uri_endpoint,
                          self._handle_authentication_response,
                          methods=['GET', 'POST'])
 
         # dynamically add the Flask redirect uri to the client info
         with app.app_context():
             self.clients = {
-                name: PyoidcFacade(configuration, url_for(self.REDIRECT_URI_ENDPOINT))
+                name: PyoidcFacade(configuration, url_for(self._redirect_uri_endpoint))
                 for (name, configuration) in self._provider_configurations.items()
             }
 
-    def _get_post_logout_redirect_uri(self):
-        if self._logout_view:
-            return url_for(self._logout_view.__name__, _external=True)
-        return None
+    def _get_post_logout_redirect_uri(self, client):
+        if client.post_logout_redirect_uris:
+            return client.post_logout_redirect_uris[0]
+        return self._get_url_for_logout_view()
+
+    def _get_url_for_logout_view(self):
+        return url_for(self._logout_view.__name__, _external=True) if self._logout_view else None
 
     def _register_client(self, client):
+        def default_post_logout_redirect_uris():
+            url_for_logout_view = self._get_url_for_logout_view()
+            if url_for_logout_view:
+                return [url_for_logout_view]
+            return []
+
         client_registration_args = {}
-        post_logout_redirect_uri = self._get_post_logout_redirect_uri()
-        if post_logout_redirect_uri:
-            logger.debug('registering with post_logout_redirect_uri=%s', post_logout_redirect_uri)
-            client_registration_args['post_logout_redirect_uris'] = [post_logout_redirect_uri]
+        post_logout_redirect_uris = client._provider_configuration._client_registration_info.get('post_logout_redirect_uris',
+                                                                                                 default_post_logout_redirect_uris())
+        if post_logout_redirect_uris:
+            logger.debug('registering with post_logout_redirect_uris=%s', post_logout_redirect_uris)
+            client_registration_args['post_logout_redirect_uris'] = post_logout_redirect_uris
         client.register(client_registration_args)
 
     def _authenticate(self, client, interactive=True):
@@ -161,7 +173,7 @@ class OIDCAuthentication(object):
             # if the current request was from the JS page handling fragment encoded responses we need to return
             # a URL for the error page to redirect to
             flask.session['error'] = error_response
-            return '/redirect_uri?error=1'
+            return '/' + self._redirect_uri_endpoint + '?error=1'
         return self._show_error_response(error_response)
 
     def _show_error_response(self, error_response):
@@ -210,7 +222,7 @@ class OIDCAuthentication(object):
             flask.session['end_session_state'] = rndstr()
 
             end_session_request = EndSessionRequest(id_token_hint=id_token_jwt,
-                                                    post_logout_redirect_uri=self._get_post_logout_redirect_uri(),
+                                                    post_logout_redirect_uri=self._get_post_logout_redirect_uri(client),
                                                     state=flask.session['end_session_state'])
 
             logger.debug('send endsession request: %s', end_session_request.to_json())
diff --git a/src/flask_pyoidc/pyoidc_facade.py b/src/flask_pyoidc/pyoidc_facade.py
index 5d8adf3..aea1214 100644
--- a/src/flask_pyoidc/pyoidc_facade.py
+++ b/src/flask_pyoidc/pyoidc_facade.py
@@ -171,6 +171,10 @@ class PyoidcFacade(object):
         provider_metadata = self._provider_configuration.ensure_provider_metadata()
         return provider_metadata.get('end_session_endpoint')
 
+    @property
+    def post_logout_redirect_uris(self):
+        return self._client.registration_response.get('post_logout_redirect_uris')
+
     def _parse_response(self, response_params, success_response_cls, error_response_cls):
         if 'error' in response_params:
             response = error_response_cls(**response_params)
diff --git a/tox.ini b/tox.ini
index 6565ab8..6367f6a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27,py34,py35,py36
+envlist = py27,py34,py35,py36,py37
 
 [testenv]
 commands = py.test tests/ example/


Test patch:
diff --git a/tests/test_flask_pyoidc.py b/tests/test_flask_pyoidc.py
index 78cde1a..f1dddf5 100644
--- a/tests/test_flask_pyoidc.py
+++ b/tests/test_flask_pyoidc.py
@@ -124,15 +124,22 @@ class TestOIDCAuthentication(object):
         self.assert_auth_redirect(auth_redirect)
 
     @responses.activate
-    def test_should_register_client_if_not_registered_before(self):
+    @pytest.mark.parametrize('post_logout_redirect_uris', [
+        None,
+        ['https://example.com/post_logout']
+    ])
+    def test_should_register_client_if_not_registered_before(self, post_logout_redirect_uris):
         registration_endpoint = self.PROVIDER_BASEURL + '/register'
         provider_metadata = ProviderMetadata(self.PROVIDER_BASEURL,
                                              self.PROVIDER_BASEURL + '/auth',
                                              self.PROVIDER_BASEURL + '/jwks',
                                              registration_endpoint=registration_endpoint)
+        client_metadata = {}
+        if post_logout_redirect_uris:
+            client_metadata['post_logout_redirect_uris'] = post_logout_redirect_uris
         provider_configurations = {
             self.PROVIDER_NAME: ProviderConfiguration(provider_metadata=provider_metadata,
-                                                      client_registration_info=ClientRegistrationInfo())
+                                                      client_registration_info=ClientRegistrationInfo(**client_metadata))
         }
         authn = OIDCAuthentication(provider_configurations)
         authn.init_app(self.app)
@@ -149,9 +156,10 @@ class TestOIDCAuthentication(object):
 
         self.assert_auth_redirect(auth_redirect)
         registration_request = json.loads(responses.calls[0].request.body.decode('utf-8'))
+        expected_post_logout_redirect_uris = post_logout_redirect_uris if post_logout_redirect_uris else ['http://{}/logout'.format(self.CLIENT_DOMAIN)]
         expected_registration_request = {
             'redirect_uris': ['http://{}/redirect_uri'.format(self.CLIENT_DOMAIN)],
-            'post_logout_redirect_uris': ['http://{}/logout'.format(self.CLIENT_DOMAIN)]
+            'post_logout_redirect_uris': expected_post_logout_redirect_uris
         }
         assert registration_request == expected_registration_request
 
@@ -335,22 +343,31 @@ class TestOIDCAuthentication(object):
         cookie_lifetime = (parsed_expiration - datetime.utcnow()).total_seconds()
         assert cookie_lifetime == pytest.approx(session_lifetime, abs=1)
 
-    def test_logout_redirects_to_provider_if_end_session_endpoint_is_configured(self):
+    @pytest.mark.parametrize('post_logout_redirect_uri', [
+        None,
+        'https://example.com/post_logout'
+    ])
+    def test_logout_redirects_to_provider_if_end_session_endpoint_is_configured(self, post_logout_redirect_uri):
         end_session_endpoint = 'https://provider.example.com/end_session'
-        authn = self.init_app(provider_metadata_extras={'end_session_endpoint': end_session_endpoint})
+        client_metadata = {}
+        if post_logout_redirect_uri:
+            client_metadata['post_logout_redirect_uris'] = [post_logout_redirect_uri]
+
+        authn = self.init_app(provider_metadata_extras={'end_session_endpoint': end_session_endpoint},
+                              client_metadata_extras=client_metadata)
         logout_view_mock = self.get_view_mock()
         id_token = IdToken(**{'sub': 'sub1', 'nonce': 'nonce'})
 
         # register logout view
-        self.app.add_url_rule('/logout', view_func=authn.oidc_logout(logout_view_mock))
+        view_func = authn.oidc_logout(logout_view_mock)
+        self.app.add_url_rule('/logout', view_func=view_func)
 
         with self.app.test_request_context('/logout'):
             UserSession(flask.session, self.PROVIDER_NAME).update('test_access_token',
                                                                   id_token.to_dict(),
                                                                   id_token.to_jwt(),
                                                                   {'sub': 'user1'})
-
-            end_session_redirect = authn.oidc_logout(logout_view_mock)()
+            end_session_redirect = view_func()
             # ensure user session has been cleared
             assert all(k not in flask.session for k in UserSession.KEYS)
             parsed_request = dict(parse_qsl(urlparse(end_session_redirect.headers['Location']).query))
@@ -359,7 +376,9 @@ class TestOIDCAuthentication(object):
         assert end_session_redirect.status_code == 303
         assert end_session_redirect.location.startswith(end_session_endpoint)
         assert IdToken().from_jwt(parsed_request['id_token_hint']) == id_token
-        assert parsed_request['post_logout_redirect_uri'] == 'http://{}/logout'.format(self.CLIENT_DOMAIN)
+
+        expected_post_logout_redirect_uri = post_logout_redirect_uri if post_logout_redirect_uri else 'http://{}/logout'.format(self.CLIENT_DOMAIN)
+        assert parsed_request['post_logout_redirect_uri'] == expected_post_logout_redirect_uri
         assert not logout_view_mock.called
 
     def test_logout_handles_provider_without_end_session_endpoint(self):
@@ -468,3 +487,9 @@ class TestOIDCAuthentication(object):
         with pytest.raises(ValueError) as exc_info:
             self.init_app().oidc_auth('unknown')
         assert 'unknown' in str(exc_info.value)
+
+    def test_should_use_custom_redirect_endpoint(self):
+        self.app.config['OIDC_REDIRECT_ENDPOINT'] = '/openid_connect_login'
+        authn = self.init_app()
+        assert authn._redirect_uri_endpoint == 'openid_connect_login'
+        assert authn.clients['test_provider']._redirect_uri == 'http://client.example.com/openid_connect_login'