Skip to content

Commit b8f4d78

Browse files
marnanelauvipy
authored andcommitted
Fix for issue #235 (urn:ietf:wg:oauth:2.0:oob support) (#774)
* First draft of a possible fix for issue #235, adding support for urn:ietf:wg:oauth:2.0:oob and urn:ietf:wg:oauth:2.0:oob:auto redirect URLs. * rm debug code * Added extra fields for the out-of-band JSON response. See https://github.com/googleapis/oauth2client/blob/master/oauth2client/client.py#L618 for more information about these fields. * basic tests for the new JSON oob response fields * Add brief docstrings to OOB tests * Add auth code to the <title> tag for OOB requests * Remove token fields from the oob:auto JSON response: it's not used when we're returning a token * test_oob_as_html() and test_oob_as_json() tests added to test_authorization_code.py. test_oob_authorization.py, which contained earlier versions of these, deleted.
1 parent bf1525e commit b8f4d78

File tree

3 files changed

+150
-3
lines changed

3 files changed

+150
-3
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{% extends "oauth2_provider/base.html" %}
2+
3+
{% load i18n %}
4+
5+
{% block title %}
6+
Success code={{code}}
7+
{% endblock %}
8+
9+
{% block content %}
10+
<div class="block-center">
11+
{% if not error %}
12+
<h2>{% trans "Success" %}</h2>
13+
14+
<p>{% trans "Please return to your application and enter this code:" %}</p>
15+
16+
<p><code>{{ code }}</code></p>
17+
18+
{% else %}
19+
<h2>Error: {{ error.error }}</h2>
20+
<p>{{ error.description }}</p>
21+
{% endif %}
22+
</div>
23+
{% endblock %}

oauth2_provider/views/base.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import json
22
import logging
3+
import urllib.parse
34

45
from django.contrib.auth.mixins import LoginRequiredMixin
5-
from django.http import HttpResponse
6+
from django.http import HttpResponse, JsonResponse
67
from django.utils import timezone
78
from django.utils.decorators import method_decorator
89
from django.views.decorators.csrf import csrf_exempt
910
from django.views.decorators.debug import sensitive_post_parameters
1011
from django.views.generic import FormView, View
12+
from django.shortcuts import render
13+
from django.urls import reverse
1114

1215
from ..exceptions import OAuthToolkitError
1316
from ..forms import AllowForm
@@ -18,7 +21,6 @@
1821
from ..signals import app_authorized
1922
from .mixins import OAuthLibMixin
2023

21-
2224
log = logging.getLogger("oauth2_provider")
2325

2426

@@ -59,6 +61,7 @@ def redirect(self, redirect_to, application):
5961
allowed_schemes = application.get_allowed_schemes()
6062
return OAuth2ResponseRedirect(redirect_to, allowed_schemes)
6163

64+
RFC3339 = '%Y-%m-%dT%H:%M:%SZ'
6265

6366
class AuthorizationView(BaseAuthorizationView, FormView):
6467
"""
@@ -204,13 +207,42 @@ def get(self, request, *args, **kwargs):
204207
request=self.request, scopes=" ".join(scopes),
205208
credentials=credentials, allow=True
206209
)
207-
return self.redirect(uri, application)
210+
return self.redirect(uri, application, token)
208211

209212
except OAuthToolkitError as error:
210213
return self.error_response(error, application)
211214

212215
return self.render_to_response(self.get_context_data(**kwargs))
213216

217+
def redirect(self, redirect_to, application,
218+
token = None):
219+
220+
if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"):
221+
return super().redirect(redirect_to, application)
222+
223+
parsed_redirect = urllib.parse.urlparse(redirect_to)
224+
code = urllib.parse.parse_qs(parsed_redirect.query)['code'][0]
225+
226+
if redirect_to.startswith('urn:ietf:wg:oauth:2.0:oob:auto'):
227+
228+
response = {
229+
'access_token': code,
230+
'token_uri': redirect_to,
231+
'client_id': application.client_id,
232+
'client_secret': application.client_secret,
233+
'revoke_uri': reverse('oauth2_provider:revoke-token'),
234+
}
235+
236+
return JsonResponse(response)
237+
238+
else:
239+
return render(
240+
request=self.request,
241+
template_name="oauth2_provider/authorized-oob.html",
242+
context={
243+
'code': code,
244+
},
245+
)
214246

215247
@method_decorator(csrf_exempt, name="dispatch")
216248
class TokenView(OAuthLibMixin, View):

tests/test_authorization_code.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import datetime
33
import hashlib
44
import json
5+
import re
56
from urllib.parse import parse_qs, urlencode, urlparse
67

78
from django.contrib.auth import get_user_model
@@ -27,6 +28,8 @@
2728
RefreshToken = get_refresh_token_model()
2829
UserModel = get_user_model()
2930

31+
URI_OOB = "urn:ietf:wg:oauth:2.0:oob"
32+
URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto"
3033

3134
# mocking a protected resource view
3235
class ResourceView(ProtectedResourceView):
@@ -46,6 +49,7 @@ def setUp(self):
4649
name="Test Application",
4750
redirect_uris=(
4851
"http://localhost http://example.com http://example.org custom-scheme://example.com"
52+
" " + URI_OOB + " " + URI_OOB_AUTO
4953
),
5054
user=self.dev_user,
5155
client_type=Application.CLIENT_CONFIDENTIAL,
@@ -1456,6 +1460,94 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param
14561460
self.assertEqual(content["scope"], "read write")
14571461
self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
14581462

1463+
def test_oob_as_html(self):
1464+
"""
1465+
Test out-of-band authentication.
1466+
"""
1467+
self.client.login(username="test_user", password="123456")
1468+
1469+
authcode_data = {
1470+
"client_id": self.application.client_id,
1471+
"state": "random_state_string",
1472+
"scope": "read write",
1473+
"redirect_uri": URI_OOB,
1474+
"response_type": "code",
1475+
"allow": True,
1476+
}
1477+
1478+
response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data)
1479+
self.assertEqual(response.status_code, 200)
1480+
self.assertRegex(response['Content-Type'], r'^text/html')
1481+
1482+
content = response.content.decode("utf-8")
1483+
1484+
# "A lot of applications, for legacy reasons, use this and regex
1485+
# to extract the token, risking summoning zalgo in the process."
1486+
# -- https://github.com/jazzband/django-oauth-toolkit/issues/235
1487+
1488+
matches = re.search(r'.*<code>([^<>]*)</code>',
1489+
content)
1490+
self.assertIsNotNone(matches,
1491+
msg="OOB response contains code inside <code> tag")
1492+
self.assertEqual(len(matches.groups()), 1,
1493+
msg="OOB response contains multiple <code> tags")
1494+
authorization_code = matches.groups()[0]
1495+
1496+
token_request_data = {
1497+
"grant_type": "authorization_code",
1498+
"code": authorization_code,
1499+
"redirect_uri": URI_OOB,
1500+
"client_id": self.application.client_id,
1501+
"client_secret": self.application.client_secret,
1502+
}
1503+
1504+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data)
1505+
self.assertEqual(response.status_code, 200)
1506+
1507+
content = json.loads(response.content.decode("utf-8"))
1508+
self.assertEqual(content["token_type"], "Bearer")
1509+
self.assertEqual(content["scope"], "read write")
1510+
self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
1511+
1512+
def test_oob_as_json(self):
1513+
"""
1514+
Test out-of-band authentication, with a JSON response.
1515+
"""
1516+
self.client.login(username="test_user", password="123456")
1517+
1518+
authcode_data = {
1519+
"client_id": self.application.client_id,
1520+
"state": "random_state_string",
1521+
"scope": "read write",
1522+
"redirect_uri": URI_OOB_AUTO,
1523+
"response_type": "code",
1524+
"allow": True,
1525+
}
1526+
1527+
response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data)
1528+
self.assertEqual(response.status_code, 200)
1529+
self.assertRegex(response['Content-Type'], '^application/json')
1530+
1531+
parsed_response = json.loads(response.content.decode("utf-8"))
1532+
1533+
self.assertIn('access_token', parsed_response)
1534+
authorization_code = parsed_response['access_token']
1535+
1536+
token_request_data = {
1537+
"grant_type": "authorization_code",
1538+
"code": authorization_code,
1539+
"redirect_uri": URI_OOB_AUTO,
1540+
"client_id": self.application.client_id,
1541+
"client_secret": self.application.client_secret,
1542+
}
1543+
1544+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data)
1545+
self.assertEqual(response.status_code, 200)
1546+
1547+
content = json.loads(response.content.decode("utf-8"))
1548+
self.assertEqual(content["token_type"], "Bearer")
1549+
self.assertEqual(content["scope"], "read write")
1550+
self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
14591551

14601552
class TestAuthorizationCodeProtectedResource(BaseTest):
14611553
def test_resource_access_allowed(self):

0 commit comments

Comments
 (0)