Skip to content

Commit

Permalink
BLUEBUTTON-1647 Demographic filter scopes phase1 (#788)
Browse files Browse the repository at this point in the history
* Updates interface to allow bene sharing choice

* Add auth template choice by feature switch

* Choose authorize template per feature switch

* Add scope share_choice to form and template

* Remove custom save_bearer_token() OAUTH2 method

* Update tests for authorize token behavior

* Update scopes.json fixture for local dev.

* Refactor block_personal_choice logic in to view

* Refactor block_personal_choice logic to form clean()

* Update tests for change of scope and new AC

* Fix test for non require-scopes switch

* Add SimpleAllowForm form tests

* Move BENE_PERSONAL_INFO_SCOPES to base settings

* Add group to scopes.json fixutre

* Update form test to use scopes.json fixture

* Update view test for block_personal_choice

* Update migrate to load scopes.json for local dev

Co-authored-by: John French <[email protected]>
  • Loading branch information
dtisza1 and johnfrenchxyz authored Mar 10, 2020
1 parent fc761d7 commit 6056bd8
Show file tree
Hide file tree
Showing 14 changed files with 271 additions and 152 deletions.
69 changes: 36 additions & 33 deletions apps/accounts/fixtures/scopes.json
Original file line number Diff line number Diff line change
@@ -1,68 +1,71 @@
[
{
"model": "auth.group",
"pk": 5,
"fields": {
"name": "BlueButton",
"permissions": []
}
},
{
"model": "capabilities.protectedcapability",
"pk": 1,
"fields": {
"title": "Beneficiary Blue Button Patient Resource",
"title": "My general patient and demographic information.",
"slug": "patient/Patient.read",
"group": 1,
"description": "This capability allows a 3rd party application to access beneficiary data when authorized by the beneficiary.",
"protected_resources": "[[\"GET\", \"/v1/fhir/Patient/[id]\"]]"
"group": 5,
"description": "Patient FHIR Resource",
"protected_resources": "[\n [\n \"GET\",\n \"/v1/fhir/Patient[/]?$\"\n ],\n [\n \"GET\",\n \"/v1/fhir/Patient[/?].*$\"\n ]\n]",
"default": "True"
}
},
{
"model": "capabilities.protectedcapability",
"pk": 2,
"fields": {
"title": "OpenID Connect Profile",
"title": "Profile information including name and email.",
"slug": "profile",
"group": 1,
"description": "Get the Open ID profile.",
"protected_resources": "[[\"GET\", \"/connect/userinfo\"]]"
"group": 5,
"description": "OIDC userinfo endpoint /connect/userinfo",
"protected_resources": "[\n [\n \"GET\",\n \"/v1/connect/userinfo.*$\"\n ]\n]",
"default": "True"
}
},
{
"model": "capabilities.protectedcapability",
"pk": 3,
"fields": {
"title": "Beneficiary Blue Button ExplanationOfBenefit Resource",
"title": "My Medicare claim information.",
"slug": "patient/ExplanationOfBenefit.read",
"group": 1,
"description": "[[\"GET\", \"/v1/fhir/ExplanationOfBenefit/[id]\"]]",
"protected_resources": "[[\"GET\", \"/v1/fhir/ExplanationOfBenefit/[id]\"]]"
"group": 5,
"description": "ExplanationOfBenefit FHIR Resource",
"protected_resources": "[\n [\n \"GET\",\n \"/v1/fhir/ExplanationOfBenefit[/]?$\"\n ],\n [\n \"GET\",\n \"/v1/fhir/ExplanationOfBenefit[/?].*$\"\n ]\n]",
"default": "True"
}
},
{
"model": "capabilities.protectedcapability",
"pk": 4,
"fields": {
"title": "OpenIDConnect",
"slug": "openid",
"group": 1,
"description": "Just a declaration.",
"protected_resources": "[]"
"title": "My Medicare and supplemental coverage information.",
"slug": "patient/Coverage.read",
"group": 5,
"description": "Coverage FHIR Resource",
"protected_resources": "[\n [\n \"GET\",\n \"/v1/fhir/Coverage[/]?$\"\n ],\n [\n \"GET\",\n \"/v1/fhir/Coverage[/?].*$\"\n ]\n]",
"default": "True"
}
},
{
"model": "capabilities.protectedcapability",
"pk": 5,
"fields": {
"title": "Beneficiary Blue Button Organization Resource",
"slug": "patient/Organization.read",
"group": 1,
"description": "Read FHIR Organization Resource",
"protected_resources": "[[\"GET\", \"/bluebutton/fhir/v1/Organization/[id]\"]]"
}
},
{
"model": "capabilities.protectedcapability",
"pk": 6,
"fields": {
"title": "Beneficiary Blue Button Coverage Resource",
"slug": "patient/Coverage.read",
"group": 1,
"description": "FHIR Coverage Resource (Read Only)",
"protected_resources": "[[\"GET\", \"/bluebutton/fhir/v1/Coverage/[id]\"]]"
"title": "Token Management",
"slug": "token_management",
"group": 5,
"description": "Allow an app to manage all of a user's tokens.",
"protected_resources": "[]",
"protected_resources": "[[\"GET\", \"/some-url\"]]",
"default": "False"
}
}
]
38 changes: 3 additions & 35 deletions apps/accounts/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,43 +96,11 @@ def test_single_access_token_issued(self):
second_access_token = self._get_access_token('john',
'123456',
application)
self.assertEqual(first_access_token, second_access_token)

def test_single_access_token_issued_when_changed_scope_allowed(self):
"""
Test that the same access token is issued when a scope is changed but
it is a subset of the old token's scope.
e.g. old_token_scope = 'read write'
new_token_scope = 'read'
"""
# create the user
self._create_user('john',
'123456',
first_name='John',
last_name='Smith',
email='[email protected]')
# create read and write capabilities
read_capability = self._create_capability('Read', [])
write_capability = self._create_capability('Write', [])
# create a oauth2 application and add capabilities
application = self._create_application('test')
application.scope.add(read_capability, write_capability)
# get the first access token for the user 'john'
first_access_token = self._get_access_token('john',
'123456',
application,
scope='read write')
# request another access token for the same user/application
second_access_token = self._get_access_token('john',
'123456',
application,
scope='read')
self.assertEqual(first_access_token, second_access_token)
self.assertNotEqual(first_access_token, second_access_token)

def test_new_access_token_issued_when_scope_added(self):
def test_new_access_token_issued_when_scope_changed(self):
"""
Test that a new access token is issued when a scope is added.
Test that a new access token is issued when a scope is changed.
e.g. old_token_scope = 'read'
new_token_scope = 'read write'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
WAFFLE_FEATURE_SWITCHES = (('outreach_email', True),
('wellknown_applications', True),
('login', True),
('signup', True))
('signup', True),
('require-scopes', True))


class Command(BaseCommand):
Expand Down
12 changes: 12 additions & 0 deletions apps/dot_ext/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,15 @@ def save(self, *args, **kwargs):
class SimpleAllowForm(DotAllowForm):
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
block_personal_choice = forms.BooleanField(required=False)

def clean(self):
cleaned_data = super().clean()
scope = cleaned_data.get("scope")

# Remove personal information scopes, if requested by bene
if cleaned_data.get("block_personal_choice"):
cleaned_data['scope'] = ' '.join([s for s in scope.split(" ")
if s not in settings.BENE_PERSONAL_INFO_SCOPES])

return cleaned_data
66 changes: 0 additions & 66 deletions apps/dot_ext/oauth2_validators.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
import math

from django.utils import timezone
from django.utils.timezone import timedelta

from oauth2_provider.models import AccessToken, RefreshToken
from oauth2_provider.oauth2_validators import OAuth2Validator
from django.core.exceptions import ObjectDoesNotExist
from apps.pkce.oauth2_validators import PKCEValidatorMixin
Expand Down Expand Up @@ -32,66 +26,6 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **k
*args,
**kwargs)

# TODO: remove this
# https://github.com/jazzband/django-oauth-toolkit/blob/f0091f17445e1481692bcebc2fc2d9b5b522b608/oauth2_provider/oauth2_validators.py#L337
def save_bearer_token(self, token, request, *args, **kwargs):
"""
Check if an access_token exists for the couple user/application
that is valid and authorized for the same scopes and ensures that
no refresh token was used.
If all the conditions are true the same access_token is issued.
Otherwise a new one is created with the default strategy.
"""
# this queryset identifies all the valid access tokens
# for the couple user/application.
previous_valid_tokens = AccessToken.objects.filter(
user=request.user, application=request.client,
).filter(expires__gt=timezone.now()).order_by('-expires')

# if a refresh token was not used and a valid token exists we
# can replace the new generated token with the old one.
if not request.refresh_token and previous_valid_tokens.exists():
for access_token in previous_valid_tokens:
# the previous access_token must allow access to the same scope
# or bigger
if access_token.allow_scopes(token['scope'].split()):
token['access_token'] = access_token.token
expires_in = access_token.expires - timezone.now()
token['expires_in'] = math.floor(expires_in.total_seconds())

if hasattr(access_token, 'refresh_token'):
token['refresh_token'] = access_token.refresh_token.token

# break the loop and exist because we found to old token
return

# default behaviour when no old token is found
if request.refresh_token:
# remove used refresh token
RefreshToken.objects.get(token=request.refresh_token).revoke()

expires = timezone.now() + timedelta(seconds=token['expires_in'])
if request.grant_type == 'client_credentials':
request.user = None

access_token = AccessToken(
user=request.user,
scope=token['scope'],
expires=expires,
token=token['access_token'],
application=request.client)
access_token.save()

if 'refresh_token' in token:
refresh_token = RefreshToken(
user=request.user,
token=token['refresh_token'],
application=request.client,
access_token=access_token
)
refresh_token.save()

def get_original_scopes(self, refresh_token, request, *args, **kwargs):
try:
return super().get_original_scopes(refresh_token, request, *args, **kwargs)
Expand Down
File renamed without changes.
45 changes: 45 additions & 0 deletions apps/dot_ext/tests/test_form_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from apps.dot_ext.forms import SimpleAllowForm
from apps.dot_ext.scopes import CapabilitiesScopes
from apps.test import BaseApiTest
from django.conf import settings


class TestSimpleAllowFormForm(BaseApiTest):
fixtures = ['scopes.json']

def test_form(self):
"""
Test form related to scopes and BENE block_personal_choice.
"""
full_scopes_list = CapabilitiesScopes().get_default_scopes()
non_personal_scopes_list = list(set(full_scopes_list) - set(settings.BENE_PERSONAL_INFO_SCOPES))

data = {'redirect_uri': 'http://localhost:3000/bluebutton/callback/',
'scope': ' '.join(full_scopes_list),
'client_id': 'AAAAAAAAAA1111111111111111AAAAAAAAAAAAAA',
'state': 'ba0a6e3c704ced52c7788331e6bab262',
'response_type': 'code',
'code_challenge': '',
'code_challenge_method': '',
'allow': 'Allow'}

# 1. Test with block_personal_choice = False
# Should have full scopes list.
data['block_personal_choice'] = 'False'
form = SimpleAllowForm(data)
self.assertTrue(form.is_valid())
cleaned_data = form.cleaned_data

self.assertNotEqual(cleaned_data['scope'].split(), None)
self.assertEqual(sorted(full_scopes_list),
sorted(cleaned_data['scope'].split()))

# 2. Test with block_personal_choice = True
# Should have non personal scopes list.
data['block_personal_choice'] = 'True'
form = SimpleAllowForm(data)
self.assertTrue(form.is_valid())
cleaned_data = form.cleaned_data
self.assertNotEqual(cleaned_data['scope'].split(), None)
self.assertEqual(sorted(non_personal_scopes_list),
sorted(cleaned_data['scope'].split()))
Loading

0 comments on commit 6056bd8

Please sign in to comment.