Skip to content

Commit d17d3ea

Browse files
committed
added support for querystring in redirection uris
1 parent c0b27fb commit d17d3ea

File tree

4 files changed

+200
-4
lines changed

4 files changed

+200
-4
lines changed

oauth2_provider/compat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010

1111
# urlparse in python3 has been renamed to urllib.parse
1212
try:
13-
from urlparse import urlparse, parse_qs, urlunparse
13+
from urlparse import urlparse, parse_qs, parse_qsl, urlunparse
1414
except ImportError:
15-
from urllib.parse import urlparse, parse_qs, urlunparse
15+
from urllib.parse import urlparse, parse_qs, parse_qsl, urlunparse
1616

1717
try:
1818
from urllib import urlencode, unquote_plus

oauth2_provider/models.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from django.core.exceptions import ImproperlyConfigured
1515

1616
from .settings import oauth2_settings
17-
from .compat import AUTH_USER_MODEL
17+
from .compat import AUTH_USER_MODEL, parse_qsl, urlparse
1818
from .generators import generate_client_secret, generate_client_id
1919
from .validators import validate_uris
2020

@@ -94,7 +94,21 @@ def redirect_uri_allowed(self, uri):
9494
9595
:param uri: Url to check
9696
"""
97-
return uri in self.redirect_uris.split()
97+
for allowed_uri in self.redirect_uris.split():
98+
parsed_allowed_uri = urlparse(allowed_uri)
99+
parsed_uri = urlparse(uri)
100+
101+
if (parsed_allowed_uri.scheme == parsed_uri.scheme and
102+
parsed_allowed_uri.netloc == parsed_uri.netloc and
103+
parsed_allowed_uri.path == parsed_uri.path):
104+
105+
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
106+
uqs_set = set(parse_qsl(parsed_uri.query))
107+
108+
if aqs_set.issubset(uqs_set):
109+
return True
110+
111+
return False
98112

99113
def clean(self):
100114
from django.core.exceptions import ValidationError

oauth2_provider/tests/test_authorization_code.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,46 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self):
403403
self.assertIn('custom-scheme://example.com?', response['Location'])
404404
self.assertIn("error=access_denied", response['Location'])
405405

406+
def test_code_post_auth_redirection_uri_with_querystring(self):
407+
"""
408+
Tests that a redirection uri with query string is allowed
409+
and query string is retained on redirection.
410+
See http://tools.ietf.org/html/rfc6749#section-3.1.2
411+
"""
412+
self.client.login(username="test_user", password="123456")
413+
414+
form_data = {
415+
'client_id': self.application.client_id,
416+
'state': 'random_state_string',
417+
'scope': 'read write',
418+
'redirect_uri': 'http://example.com?foo=bar',
419+
'response_type': 'code',
420+
'allow': True,
421+
}
422+
423+
response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data)
424+
self.assertEqual(response.status_code, 302)
425+
self.assertIn("http://example.com?foo=bar", response['Location'])
426+
self.assertIn("code=", response['Location'])
427+
428+
def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self):
429+
"""
430+
Tests that a redirection uri is matched using scheme + netloc + path
431+
"""
432+
self.client.login(username="test_user", password="123456")
433+
434+
form_data = {
435+
'client_id': self.application.client_id,
436+
'state': 'random_state_string',
437+
'scope': 'read write',
438+
'redirect_uri': 'http://example.com/a?foo=bar',
439+
'response_type': 'code',
440+
'allow': True,
441+
}
442+
443+
response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data)
444+
self.assertEqual(response.status_code, 400)
445+
406446

407447
class TestAuthorizationCodeTokenView(BaseTest):
408448
def get_auth(self):
@@ -759,6 +799,108 @@ def test_malicious_redirect_uri(self):
759799
response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data)
760800
self.assertEqual(response.status_code, 401)
761801

802+
def test_code_exchange_succeed_when_redirect_uri_match(self):
803+
"""
804+
Tests code exchange succeed when redirect uri matches the one used for code request
805+
"""
806+
self.client.login(username="test_user", password="123456")
807+
808+
# retrieve a valid authorization code
809+
authcode_data = {
810+
'client_id': self.application.client_id,
811+
'state': 'random_state_string',
812+
'scope': 'read write',
813+
'redirect_uri': 'http://example.it?foo=bar',
814+
'response_type': 'code',
815+
'allow': True,
816+
}
817+
response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data)
818+
query_dict = parse_qs(urlparse(response['Location']).query)
819+
authorization_code = query_dict['code'].pop()
820+
821+
# exchange authorization code for a valid access token
822+
token_request_data = {
823+
'grant_type': 'authorization_code',
824+
'code': authorization_code,
825+
'redirect_uri': 'http://example.it?foo=bar'
826+
}
827+
auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret)
828+
829+
response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers)
830+
self.assertEqual(response.status_code, 200)
831+
832+
content = json.loads(response.content.decode("utf-8"))
833+
self.assertEqual(content['token_type'], "Bearer")
834+
self.assertEqual(content['scope'], "read write")
835+
self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
836+
837+
def test_code_exchange_fails_when_redirect_uri_does_not_match(self):
838+
"""
839+
Tests code exchange fails when redirect uri does not match the one used for code request
840+
"""
841+
self.client.login(username="test_user", password="123456")
842+
843+
# retrieve a valid authorization code
844+
authcode_data = {
845+
'client_id': self.application.client_id,
846+
'state': 'random_state_string',
847+
'scope': 'read write',
848+
'redirect_uri': 'http://example.it?foo=bar',
849+
'response_type': 'code',
850+
'allow': True,
851+
}
852+
response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data)
853+
query_dict = parse_qs(urlparse(response['Location']).query)
854+
authorization_code = query_dict['code'].pop()
855+
856+
# exchange authorization code for a valid access token
857+
token_request_data = {
858+
'grant_type': 'authorization_code',
859+
'code': authorization_code,
860+
'redirect_uri': 'http://example.it?foo=baraa'
861+
}
862+
auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret)
863+
864+
response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers)
865+
self.assertEqual(response.status_code, 401)
866+
867+
def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self):
868+
"""
869+
Tests code exchange succeed when redirect uri matches the one used for code request
870+
"""
871+
self.client.login(username="test_user", password="123456")
872+
self.application.redirect_uris = "http://localhost http://example.com?foo=bar"
873+
self.application.save()
874+
875+
# retrieve a valid authorization code
876+
authcode_data = {
877+
'client_id': self.application.client_id,
878+
'state': 'random_state_string',
879+
'scope': 'read write',
880+
'redirect_uri': 'http://example.com?bar=baz&foo=bar',
881+
'response_type': 'code',
882+
'allow': True,
883+
}
884+
response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data)
885+
query_dict = parse_qs(urlparse(response['Location']).query)
886+
authorization_code = query_dict['code'].pop()
887+
888+
# exchange authorization code for a valid access token
889+
token_request_data = {
890+
'grant_type': 'authorization_code',
891+
'code': authorization_code,
892+
'redirect_uri': 'http://example.com?bar=baz&foo=bar'
893+
}
894+
auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret)
895+
896+
response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers)
897+
self.assertEqual(response.status_code, 200)
898+
899+
content = json.loads(response.content.decode("utf-8"))
900+
self.assertEqual(content['token_type'], "Bearer")
901+
self.assertEqual(content['scope'], "read write")
902+
self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
903+
762904

763905
class TestAuthorizationCodeProtectedResource(BaseTest):
764906
def test_resource_access_allowed(self):

oauth2_provider/tests/test_implicit.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,46 @@ def test_token_post_auth_deny(self):
185185
self.assertEqual(response.status_code, 302)
186186
self.assertIn("error=access_denied", response['Location'])
187187

188+
def test_implicit_redirection_uri_with_querystring(self):
189+
"""
190+
Tests that a redirection uri with query string is allowed
191+
and query string is retained on redirection.
192+
See http://tools.ietf.org/html/rfc6749#section-3.1.2
193+
"""
194+
self.client.login(username="test_user", password="123456")
195+
196+
form_data = {
197+
'client_id': self.application.client_id,
198+
'state': 'random_state_string',
199+
'scope': 'read write',
200+
'redirect_uri': 'http://example.com?foo=bar',
201+
'response_type': 'token',
202+
'allow': True,
203+
}
204+
205+
response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data)
206+
self.assertEqual(response.status_code, 302)
207+
self.assertIn("http://example.com?foo=bar", response['Location'])
208+
self.assertIn("access_token=", response['Location'])
209+
210+
def test_implicit_fails_when_redirect_uri_path_is_invalid(self):
211+
"""
212+
Tests that a redirection uri is matched using scheme + netloc + path
213+
"""
214+
self.client.login(username="test_user", password="123456")
215+
216+
form_data = {
217+
'client_id': self.application.client_id,
218+
'state': 'random_state_string',
219+
'scope': 'read write',
220+
'redirect_uri': 'http://example.com/a?foo=bar',
221+
'response_type': 'code',
222+
'allow': True,
223+
}
224+
225+
response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data)
226+
self.assertEqual(response.status_code, 400)
227+
188228

189229
class TestImplicitTokenView(BaseTest):
190230
def test_resource_access_allowed(self):

0 commit comments

Comments
 (0)