diff --git a/README.md b/README.md index 9bf667c..761238c 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,69 @@ Firefox Accounts Redirect } }); +Firefox Accounts Refresh +---- + Refresh a Firefox Accounts access token for a new token using a refresh token. + +* **URL** + + https://leaderboard.services.mozilla.com/api/v1/fxa/refresh/ + +* **Method:** + + `POST` + +* **URL Params** + + None + +* **Data Params** + + * **refresh_token** + + A long lived Firefox Accounts refresh token retrieved through an Oauth redirect process. + +* **Request Headers** + + * Authorization + + A successful submission must include a valid Firefox Accounts authorization + bearer token + + Example: `Authorization: Bearer kf94k5jsgsl3kj` + +* **Success Response:** + + * **Code:** 200 + + JSON encoding + + { + access_token: , + expires_in: , + scope: , + token_type: + } + +* **Error Responses:** + + * **Code:** 400 INVALID + * **Content:** `{"detail":"JSON parse error - Expecting object: line 1 column 1 (char 0)"}` + + * **Code:** 401 UNAUTHORIZED + * **Content:** `{"detail":"Unable to determine a valid Firefox Accounts authorization token"}` + +* **Sample Call:** + + $.ajax({ + url: "https://leaderboard.services.mozilla.com/api/v1/fxa/refresh/?refresh_token=asdf", + dataType: "json", + type : "GET", + success : function(r, data) { + console.log(data); + } + }); + Get Countries ---- Get a list of all countries that have been contributed to, the total number of contributions, diff --git a/leaderboard/fxa/client.py b/leaderboard/fxa/client.py index 377debf..2a9f497 100644 --- a/leaderboard/fxa/client.py +++ b/leaderboard/fxa/client.py @@ -79,6 +79,7 @@ def get_authorization_token(self, code): { 'access_token': 'asdf', + 'refresh_token': 'asdf', 'auth_at': 1438019181, 'expires_in': 172800, 'scope': 'profile', @@ -86,6 +87,7 @@ def get_authorization_token(self, code): } """ params = { + 'grant_type': 'authorization_code', 'client_id': settings.FXA_CLIENT_ID, 'client_secret': settings.FXA_SECRET, 'code': code, @@ -96,6 +98,32 @@ def get_authorization_token(self, code): return self._parse_response(response) + def refresh_authorization_token(self, refresh_token): + """ + Exchange a refresh token for a new access token. + + Example response: + + { + 'access_token': 'asdf', + 'expires_in': 1209600, + 'scope': 'profile', + 'token_type': 'bearer'} + } + """ + params = { + 'grant_type': 'refresh_token', + 'client_id': settings.FXA_CLIENT_ID, + 'client_secret': settings.FXA_SECRET, + 'scope': settings.FXA_SCOPE, + 'refresh_token': refresh_token, + } + + token_url = urlparse.urljoin(settings.FXA_OAUTH_URI, 'v1/token') + response = requests.post(token_url, data=json.dumps(params)) + + return self._parse_response(response) + def get_profile_data(self, access_token): """ Retrieve the profile details for a user given a valid access_token. diff --git a/leaderboard/fxa/tests/test_client.py b/leaderboard/fxa/tests/test_client.py index 22e22a0..422acc2 100644 --- a/leaderboard/fxa/tests/test_client.py +++ b/leaderboard/fxa/tests/test_client.py @@ -71,6 +71,23 @@ def test_get_authorization_token_returns_token(self): self.assertEqual(response_data, authorization_data) + def test_get_refresh_token_returns_token(self): + authorization_data = { + 'access_token': 'abcdef', + 'expires_in': 123, + 'scope': 'profile', + 'token_type': 'bearer' + } + + response = mock.MagicMock() + response.content = json.dumps(authorization_data) + response.status_code = 200 + self.mock_post.return_value = response + + response_data = self.fxa_client.refresh_authorization_token('asdf') + + self.assertEqual(response_data, authorization_data) + def test_get_profile_data_returns_profile(self): profile_data = { 'email': 'email@-example.com', diff --git a/leaderboard/fxa/tests/test_views.py b/leaderboard/fxa/tests/test_views.py index 42b5ec6..0819441 100644 --- a/leaderboard/fxa/tests/test_views.py +++ b/leaderboard/fxa/tests/test_views.py @@ -3,6 +3,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase +from leaderboard.contributors.tests.test_models import ContributorFactory from leaderboard.contributors.models import Contributor from leaderboard.fxa.client import get_fxa_login_url from leaderboard.fxa.tests.test_client import MockRequestTestMixin @@ -182,3 +183,65 @@ def test_multiple_contributors_signin_creates_multiple_contributors(self): self.assertEqual(contributor.fxa_uid, fxa_profile_data2['uid']) self.assertTrue(contributor.uid is not None) + + +class TestFXARefreshView(MockRequestTestMixin, TestCase): + + def setUp(self): + super(TestFXARefreshView, self).setUp() + fxa_profile_data = self.setup_profile_call() + self.contributor = ContributorFactory(fxa_uid=fxa_profile_data['uid']) + + def test_successful_refresh_returns_new_token(self): + fxa_auth_data = self.setup_auth_call() + + response = self.client.post( + reverse('fxa-refresh'), + data={'refresh_token': 'asdf'}, + HTTP_AUTHORIZATION='Bearer asdf', + ) + + self.assertEqual(response.status_code, 200) + + response_data = json.loads(response.content) + + self.assertEqual(response_data, fxa_auth_data) + + def test_missing_access_token_raises_403(self): + response = self.client.post( + reverse('fxa-refresh'), + data={'refresh_token': 'asdf'}, + ) + + self.assertEqual(response.status_code, 401) + + def test_invalid_access_token_raises_403(self): + self.set_mock_response(self.mock_get, status_code=400) + + response = self.client.post( + reverse('fxa-refresh'), + data={'refresh_token': 'asdf'}, + HTTP_AUTHORIZATION='Bearer asdf', + ) + + self.assertEqual(response.status_code, 401) + + def test_missing_refresh_token_raises_400(self): + response = self.client.post( + reverse('fxa-refresh'), + data={}, + HTTP_AUTHORIZATION='Bearer asdf', + ) + + self.assertEqual(response.status_code, 400) + + def test_fxa_auth_error_raises_400(self): + self.set_mock_response(self.mock_post, status_code=400) + + response = self.client.post( + reverse('fxa-refresh'), + data={'refresh_token': 'asdf'}, + HTTP_AUTHORIZATION='Bearer asdf', + ) + + self.assertEqual(response.status_code, 400) diff --git a/leaderboard/fxa/urls.py b/leaderboard/fxa/urls.py index 4cfe231..81c4960 100644 --- a/leaderboard/fxa/urls.py +++ b/leaderboard/fxa/urls.py @@ -1,6 +1,11 @@ from django.conf.urls import url -from leaderboard.fxa.views import FXALoginView, FXAConfigView, FXARedirectView +from leaderboard.fxa.views import ( + FXALoginView, + FXAConfigView, + FXARedirectView, + FXARefreshView, +) urlpatterns = [ url('^login/', FXALoginView.as_view(), @@ -9,4 +14,6 @@ name='fxa-config'), url('^redirect/', FXARedirectView.as_view(), name='fxa-redirect'), + url('^refresh/', FXARefreshView.as_view(), + name='fxa-refresh'), ] diff --git a/leaderboard/fxa/views.py b/leaderboard/fxa/views.py index 90f3bc1..ef27ce6 100644 --- a/leaderboard/fxa/views.py +++ b/leaderboard/fxa/views.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from leaderboard.contributors.models import Contributor +from leaderboard.fxa.authenticator import OAuthTokenAuthentication from leaderboard.fxa.client import ( get_fxa_login_url, FXAClientMixin, @@ -86,3 +87,22 @@ def get(self, request): }, content_type='application/json', ) + + +class FXARefreshView(FXAClientMixin, APIView): + authentication_classes = (OAuthTokenAuthentication,) + + def post(self, request): + refresh_token = request.POST.get('refresh_token', None) + + if refresh_token is None: + raise ValidationError('Unable to determine refresh token.') + + try: + fxa_auth_data = self.fxa_client.refresh_authorization_token( + refresh_token) + except FXAException: + raise ValidationError( + 'Unable to communicate with Firefox Accounts.') + + return Response(fxa_auth_data, content_type='application/json')