diff --git a/common/test/acceptance/pages/lms/oauth2_confirmation.py b/common/test/acceptance/pages/lms/oauth2_confirmation.py new file mode 100644 index 0000000000..f949b3267f --- /dev/null +++ b/common/test/acceptance/pages/lms/oauth2_confirmation.py @@ -0,0 +1,48 @@ +"""Pages relevant for OAuth2 confirmation.""" +from common.test.acceptance.pages.lms import BASE_URL + +from bok_choy.page_object import PageObject + + +class OAuth2Confirmation(PageObject): + """Page for OAuth2 confirmation view.""" + def __init__(self, browser, client_id="test-id", scopes=("email",)): + super(OAuth2Confirmation, self).__init__(browser) + self.client_id = client_id + self.scopes = scopes + + @property + def url(self): + return "{}/oauth2/authorize?client_id={}&response_type=code&scope={}".format( + BASE_URL, self.client_id, ' '.join(self.scopes)) + + def is_browser_on_page(self): + return self.q(css="body.oauth2").visible + + def cancel(self): + """ + Cancel the request. + + This redirects to an invalid URI, because we don't want actual network + connections being made. + """ + self.q(css="input[name=cancel]").click() + + def confirm(self): + """ + Confirm OAuth access + + This redirects to an invalid URI, because we don't want actual network + connections being made. + """ + self.q(css="input[name=authorize]").click() + + @property + def has_error(self): + """Boolean for if the page has an error or not.""" + return self.q(css=".error").present + + @property + def error_message(self): + """Text of the page's error message.""" + return self.q(css='.error').text[0] diff --git a/common/test/acceptance/tests/lms/test_oauth2.py b/common/test/acceptance/tests/lms/test_oauth2.py new file mode 100644 index 0000000000..9bcf4cc5d7 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_oauth2.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""Tests for OAuth2 permission delegation.""" +from common.test.acceptance.pages.lms.oauth2_confirmation import OAuth2Confirmation +from common.test.acceptance.pages.lms.auto_auth import AutoAuthPage +from bok_choy.web_app_test import WebAppTest + +from urlparse import urlparse, parse_qsl + + +class OAuth2PermissionDelegationTests(WebAppTest): + """ + Tests for acceptance/denial of permission delegation requests. + """ + + def setUp(self): + super(OAuth2PermissionDelegationTests, self).setUp() + self.oauth_page = OAuth2Confirmation(self.browser) + + def _auth(self): + """Authenticate the user.""" + AutoAuthPage(self.browser).visit() + + def _qs(self, url): + """Parse url's querystring into a dict.""" + return dict(parse_qsl(urlparse(url).query)) + + def test_error_for_invalid_scopes(self): + """Requests for invalid scopes throw errors.""" + self._auth() + self.oauth_page.scopes = ('email', 'does-not-exist') + assert self.oauth_page.visit() + + self.assertTrue(self.oauth_page.has_error) + self.assertIn('not a valid scope', self.oauth_page.error_message) + + def test_cancelling_redirects(self): + """ + If you cancel the request, you're redirected to the redirect_url with a + denied query param. + """ + self._auth() + assert self.oauth_page.visit() + self.oauth_page.cancel() + + # This redirects to an invalid URI. + query = self._qs(self.browser.current_url) + self.assertEqual('access_denied', query['error']) + + def test_accepting_redirects(self): + """ + If you accept the request, you're redirected to the redirect_url with + the correct query params. + """ + self._auth() + assert self.oauth_page.visit() + self.oauth_page.confirm() + + # This redirects to an invalid URI. + query = self._qs(self.browser.current_url) + self.assertIn('code', query) diff --git a/common/test/db_fixtures/oauth.json b/common/test/db_fixtures/oauth.json new file mode 100644 index 0000000000..7f6425bb9d --- /dev/null +++ b/common/test/db_fixtures/oauth.json @@ -0,0 +1,15 @@ +[ + { + "fields": { + "client_id": "test-id", + "client_secret": "test-secret", + "client_type": 0, + "name": "Test OAuth2 Client", + "redirect_uri": "http://does-not-exist/", + "url": "http://does-not-exist/", + "user": null + }, + "model": "oauth2.client", + "pk": 3 + } +] diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 310ffbc7f5..1990473417 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -105,6 +105,9 @@ for log_name, log_level in LOG_OVERRIDES: # Enable milestones app FEATURES['MILESTONES_APP'] = True +# Enable oauth authentication, which we test. +FEATURES['ENABLE_OAUTH2_PROVIDER'] = True + # Enable pre-requisite course FEATURES['ENABLE_PREREQUISITE_COURSES'] = True diff --git a/lms/static/sass/_build-lms.scss b/lms/static/sass/_build-lms.scss index 03568f9fd1..88ee8ce3c0 100644 --- a/lms/static/sass/_build-lms.scss +++ b/lms/static/sass/_build-lms.scss @@ -53,6 +53,7 @@ @import 'views/shoppingcart'; @import 'views/homepage'; @import 'views/support'; +@import 'views/oauth2'; @import "views/financial-assistance"; @import 'views/bookmarks'; @import 'course/auto-cert'; diff --git a/lms/static/sass/views/_oauth2.scss b/lms/static/sass/views/_oauth2.scss new file mode 100644 index 0000000000..8fcb7459b4 --- /dev/null +++ b/lms/static/sass/views/_oauth2.scss @@ -0,0 +1,16 @@ +@import 'neat/neat'; // lib - Neat + +.oauth2 { + + @include outer-container(); + + .authorization-confirmation { + + @include span-columns(6); + @include shift(3); + + line-height: 1.5em; + padding: 50px 0; + + } +} diff --git a/lms/templates/provider/authorize.html b/lms/templates/provider/authorize.html new file mode 100644 index 0000000000..f3f34b08ed --- /dev/null +++ b/lms/templates/provider/authorize.html @@ -0,0 +1,59 @@ +{% extends "main_django.html" %} + +{% load scope i18n %} + +{% block bodyclass %}oauth2{% endblock %} + +{% block body %} +
+ {% if not error %} +

+ {% blocktrans with application_name=client.name %} + {{ application_name }} would like to access your data with the following permissions: + {% endblocktrans %} +

+ +
+ {% csrf_token %} + {{ form.errors }} + {{ form.non_field_errors }} +
+
+ +
+ + +
+
+ {% else %} +

+ {{ error }} + {{ error_description }} +

+ {% endif %} +
+{% endblock %}