Skip to content
This repository has been archived by the owner on Jul 30, 2019. It is now read-only.

Add a new flow based on the "prompt:none" scope #5

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ omit=
setup.py
manage.py
*/tests/*
*/migrations/*
ucamoauth2consent/settings/*
ucamoauth2consent/wsgi.py
.tox/*
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ indent_size=2
[*.ini]
indent_style=space
indent_size=4

[*.sh]
indent_style=space
indent_size=4
6 changes: 1 addition & 5 deletions doc/gettingstarted.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,7 @@ Issue a token
`````````````

Once the consent app is running you should be able to issue a token for a
client:

.. code-block:: bash

$ ./scripts/create-token.sh
client as outlined in :any:`tokens`.

Next steps
``````````
Expand Down
1 change: 1 addition & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ If you don't know where to start then :doc:`the getting started guide
:caption: Contents

gettingstarted
tokens
developer
configuration
ucamoauth2consent
Expand Down
57 changes: 57 additions & 0 deletions doc/tokens.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
Issuing tokens
==============

If you are running the consent app via docker-compose, you can try out granting
tokens.

Create clients
``````````````

Make sure you've created the appropriate OAuth2 clients via:

.. code-block:: bash

$ ./scripts/create-clients.sh

This will create two OAuth2 clients:

consent
An OAuth2 client for the consent app itself which uses client credentials
for authorisation and can be granted the ``hydra.consent`` scope to perform
user consent flow.

application
An example OAuth2 client for obtaining a token. This application is allowed
to perform the authorisation code flow and can request the ``example`` and
``prompt:none`` scopes.

Issue a token
`````````````

The normal flow can be demonstrated via:

.. code-block:: bash

$ ./scripts/create-token.sh example

This will create an authorisation request for the ``example`` scope. The script
prints a URL which you can paste into the browser (in a private browsing tab).
The demo Raven login page will be shown and you can log in as a test user.

Running the script a second time should issue the token immediately as your
session will be saved in a browser cookie.

The "prompt:none" flow
``````````````````````

As an extension to the usual flow, if you request the ``prompt:none`` scope, you
will never be re-directed to the login page. Request a new token via:

.. code-block:: bash

$ ./scripts/create-token.sh example,prompt:none

Open a new private browsing tab and visit the URL shown. You should see an
immediate rejection of the request. Requesting a token without ``prompt:none``
should result in the usual login box. After logging in once, further
``prompt:none`` requests should succeed.
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ services:
env_file:
- compose/base.env
hydra:
image: oryd/hydra:v0.11.1-alpine
image: oryd/hydra:v0.11.12-alpine
entrypoint: ["/tmp/start-hydra.sh"]
ports:
- "4444:4444"
Expand Down
7 changes: 7 additions & 0 deletions ravenconsent/defaultsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@
``http://hydra.invalid/``, this should be ``http://hydra.invalid/oauth2/consent/requests/``.

"""

CONSENT_PROMPT_NONE_SCOPE = 'prompt:none'
"""
Consent requests with this scope are either granted or denied based upon whether the user is
authenticated or not and will not show the Raven login if the user is not currently authenticated.

"""
12 changes: 9 additions & 3 deletions ravenconsent/hydra.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@ def retrieve_and_verify_consent(consent_id):
return verify_consent(retrieve_consent(consent_id), consent_id)


def resolve_request(request, consent, decision, grant_scopes=[]):
"""Resolve a consent request and redirect to location in request."""
def resolve_request(request, consent, decision, grant_scopes=[], reason=None):
"""
Resolve a consent request and redirect to location in request.

If *reason* is omitted, it defaults to "request was rejected".
"""
reason = reason if reason is not None else 'request was rejected'

if decision not in [Decision.ACCEPT, Decision.REJECT]:
raise ValueError('Invalid decision: {}'.format(decision))

Expand All @@ -66,7 +72,7 @@ def resolve_request(request, consent, decision, grant_scopes=[]):
else:
response = _request(
method='PATCH', url=settings.HYDRA_CONSENT_REQUESTS_ENDPOINT + consent_id + '/reject',
json={'reason': 'user rejected request'})
json={'reason': reason})

response.raise_for_status()

Expand Down
5 changes: 3 additions & 2 deletions ravenconsent/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import dateutil.tz as tz


def get_valid_consent():
def get_valid_consent(scopes=None):
scopes = scopes if scopes is not None else ['a', 'b']
return {
'id': 'test-consent',
'expiresAt': (datetime.datetime.now(tz.tzutc()) + datetime.timedelta(hours=1)).isoformat(),
'clientId': 'test-client',
'redirectUrl': 'http://invalid.invalid/',
'requestedScopes': ['a', 'b']
'requestedScopes': scopes,
}
107 changes: 76 additions & 31 deletions ravenconsent/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import unittest.mock as mock

from django.contrib.auth import get_user_model
from django.conf import settings
from django.http import HttpResponseRedirect
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse

from ravenconsent import hydra
Expand All @@ -17,7 +19,43 @@ def test_healthz(self):
self.assertEqual(r.status_code, 200)


class NoUserConsentTests(TestCase):
class ConsentParameterTestMixin:
"""
A mixin class which contains tests for parsing consent parameters. These should pass
irrespective of whether a user is logged in.

"""
def test_no_consent(self):
"""If no consent is passed, an error is reported."""
# missing consent
r = self.client.get(self.endpoint)
self.assertEqual(r.status_code, 400)
self.assertIn(b'missing_consent', r.content)

def test_slashes_in_consent(self):
"""A consent which looks like it may try to confuse our URL concatenation is provided
should fail.

"""
r = self.client.get(self.endpoint + '?consent=foo%2Fbar')
self.assertEqual(r.status_code, 400)
self.assertIn(b'bad_consent', r.content)

def test_invalid_consent(self):
"""An invalid consent id is not accepted."""
rav_patch = mock.patch('ravenconsent.hydra.retrieve_and_verify_consent')
consent = get_valid_consent()

def fail():
raise RuntimeError("I don't like your auth")

with rav_patch as retrieve_and_verify_consent:
retrieve_and_verify_consent.side_effect = fail
r = self.client.get(self.endpoint + '?consent=' + consent['id'])
self.assertEqual(r.status_code, 400)


class NoUserConsentTests(TestCase, ConsentParameterTestMixin):
"""Test consent endpoint with no user logged in."""

def setUp(self):
Expand All @@ -33,38 +71,57 @@ def test_error(self):
self.assertIn(b'test_description', r.content)

def test_login_required(self):
"""Non-error reporting redirects to login."""
r = self.client.get(self.endpoint)
"""A valid consent requires login."""
consent = get_valid_consent()
rav_patch = mock.patch('ravenconsent.hydra.retrieve_and_verify_consent')
with rav_patch as retrieve_and_verify_consent:
retrieve_and_verify_consent.side_effect = lambda _: consent
r = self.client.get(self.endpoint + '?consent=' + consent['id'])
self.assertEqual(r.status_code, 302)
self.assertTrue(r['Location'].startswith(settings.LOGIN_URL))

@override_settings(CONSENT_PROMPT_NONE_SCOPE='custom-prompt-none')
def test_prompt_none(self):
"""A valid consent with CONSENT_PROMPT_NONE_SCOPE scope redirects to a deny response."""
consent = get_valid_consent(scopes=['a', 'b', settings.CONSENT_PROMPT_NONE_SCOPE])
rav_patch = mock.patch('ravenconsent.hydra.retrieve_and_verify_consent')
rr_patch = mock.patch('ravenconsent.hydra.resolve_request')

with rav_patch as retrieve_and_verify_consent, rr_patch as resolve_request:
retrieve_and_verify_consent.side_effect = lambda _: consent
resolve_request.return_value = HttpResponseRedirect('http://test.invalid/')
r = self.client.get(self.endpoint + '?consent=' + consent['id'])

self.assertEqual(r.status_code, 302)
self.assertEqual(r['Location'], 'http://test.invalid/')

# A decision should've been made and that should be a reject
resolve_request.assert_called()
decision = resolve_request.call_args[0][2]
self.assertIs(decision, hydra.Decision.REJECT)

class UserConsentTests(TestCase):

class UserConsentTests(TestCase, ConsentParameterTestMixin):
"""Test consent endpoint with a user logged in."""

def setUp(self):
self.user = get_user_model().objects.create_user(username='test0001')
self.client.force_login(self.user)
self.endpoint = reverse('consent')

def test_no_consent(self):
"""If no consent is passed, an error is reported."""
r = self.client.get(self.endpoint)
self.assertEqual(r.status_code, 400)
self.assertIn(b'missing_consent', r.content)

def test_slashes_in_consent(self):
"""A consent which looks like it may try to confuse our URL concatenation is provided
should fail.
def test_successful_flow(self):
self._assert_consent_successful(get_valid_consent())

"""
r = self.client.get(self.endpoint + '?consent=foo%2Fbar')
self.assertEqual(r.status_code, 400)
self.assertIn(b'bad_consent', r.content)
@override_settings(CONSENT_PROMPT_NONE_SCOPE='custom-prompt-none')
def test_prompt_none(self):
"""A valid consent with CONSENT_PROMPT_NONE_SCOPE scope succeeds."""
self._assert_consent_successful(get_valid_consent(
['a', 'b', settings.CONSENT_PROMPT_NONE_SCOPE]))

def test_successful_flow(self):
def _assert_consent_successful(self, consent):
"""Helper to check a valid consent request."""
rav_patch = mock.patch('ravenconsent.hydra.retrieve_and_verify_consent')
rr_patch = mock.patch('ravenconsent.hydra.resolve_request')
consent = get_valid_consent()
with rav_patch as retrieve_and_verify_consent, rr_patch as resolve_request:
retrieve_and_verify_consent.side_effect = lambda _: consent
resolve_request.return_value = HttpResponseRedirect('http://test.invalid/')
Expand All @@ -80,15 +137,3 @@ def test_successful_flow(self):
args, kwargs = resolve_request.call_args
self.assertEqual(args[2], hydra.Decision.ACCEPT)
self.assertEqual(kwargs['grant_scopes'], consent['requestedScopes'])

def test_invalid_consent(self):
rav_patch = mock.patch('ravenconsent.hydra.retrieve_and_verify_consent')
consent = get_valid_consent()

def fail():
raise RuntimeError("I don't like your auth")

with rav_patch as retrieve_and_verify_consent:
retrieve_and_verify_consent.side_effect = fail
r = self.client.get(self.endpoint + '?consent=' + consent['id'])
self.assertEqual(r.status_code, 400)
42 changes: 35 additions & 7 deletions ravenconsent/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import render
Expand All @@ -16,13 +17,6 @@ def consent(request):
if 'error' in request.GET:
return render_error_from_request(request)

# Otherwise, delegate response to a view which requires login
return _consent_requiring_login(request)


@login_required
def _consent_requiring_login(request):
"""Handle parts of the consent flow which require a user be logged in."""
# Get consent from request
unverified_consent_id = request.GET.get('consent')

Expand All @@ -46,6 +40,20 @@ def _consent_requiring_login(request):
except Exception as e:
return render_error(request, 'cannot_verify_consent', str(e))

# With CONSENT_PROMPT_NONE_SCOPE, instead of redirecting to the account login page, simply
# reject the request out of hand if the current user is not authenticated.
requested_scopes = consent.get('requestedScopes', [])
if (settings.CONSENT_PROMPT_NONE_SCOPE in requested_scopes
and not request.user.is_authenticated):
return _reject_request(request, consent, 'user not logged in')

# Otherwise, delegate response to a view which requires login
return _consent_requiring_login(request, consent)


@login_required
def _consent_requiring_login(request, consent):
"""Handle parts of the consent flow which require a user be logged in."""
# For the moment we implicitly grant all requested scopes with no further verification or user
# input. This goes against the grain of OAuth2 in general but, for the moment, the fact we are
# running an OAuth2 server is an implementation detail and we'd like to preserve a traditional
Expand All @@ -54,10 +62,30 @@ def _consent_requiring_login(request):
# Should the use of this service become more widespread, we should re-visit this. We shall also
# need to re-visit this before Hydra v1 since Hydra will start enforcing some sort of scope
# grant flow. See https://github.com/ory/hydra/issues/772.
return _grant_request(request, consent)


def _grant_request(request, consent):
"""
Helper function to unconditionally grant a consent request and all the scopes.

:returns: redirect back to Hydra server
:rtype: django.http.response.Response
"""
return hydra.resolve_request(
request, consent, hydra.Decision.ACCEPT, grant_scopes=consent['requestedScopes'])


def _reject_request(request, consent, reason):
"""
Helper function to unconditionally reject a consent request.

:returns: redirect back to Hydra server
:rtype: django.http.response.Response
"""
return hydra.resolve_request(request, consent, hydra.Decision.REJECT, reason=reason)


def render_error_from_request(request):
"""Render error page based on error request."""
return render(request, 'ravenconsent/error.html', status=400, context={
Expand Down
11 changes: 0 additions & 11 deletions scripts/create-client.sh

This file was deleted.

Loading