Skip to content

Commit 8ca575a

Browse files
committed
Allow wildcard for redirect_urls in OAuth2 apps
1 parent faf7197 commit 8ca575a

File tree

6 files changed

+202
-0
lines changed

6 files changed

+202
-0
lines changed

ansible_wisdom/main/settings/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import os
14+
import sys
1415
from pathlib import Path
1516

1617
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -170,6 +171,21 @@
170171
'REFRESH_TOKEN_EXPIRE_SECONDS': 864_000, # = 10 days
171172
}
172173

174+
USE_WILDCARD_IN_REDIRECT_URI = bool(
175+
os.environ.get('USE_WILDCARD_IN_REDIRECT_URI', True)
176+
)
177+
#
178+
# We need to run 'manage.py migrate' before adding our own OAuth2 application model.
179+
# See https://django-oauth-toolkit.readthedocs.io/en/latest/advanced_topics.html
180+
# #extending-the-application-model
181+
#
182+
# Also, if these lines are executed in testing, test fails with:
183+
# django.db.utils.ProgrammingError: relation "users_user" does not exist
184+
#
185+
if USE_WILDCARD_IN_REDIRECT_URI and sys.argv[1:2] not in [['migrate'], ['test']]:
186+
INSTALLED_APPS.append('wildcard_oauth2')
187+
OAUTH2_PROVIDER_APPLICATION_MODEL = 'wildcard_oauth2.Application'
188+
173189
# OAUTH: todo
174190
# - remove ansible_wisdom/users/auth.py module
175191
# - remove ansible_wisdom/users/views.py module
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
The module offers a custom OAuth2 Application that allows wildcard URLs.
3+
(From: https://github.com/open-craft/oauth2-wildcard-application)
4+
"""
5+
6+
7+
default_app_config = (
8+
'wildcard_oauth2.apps.WildcardOauth2ApplicationConfig' # pylint: disable=invalid-name
9+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""
2+
Django App Configuration
3+
"""
4+
5+
from django.apps import AppConfig
6+
7+
8+
class WildcardOauth2ApplicationConfig(AppConfig):
9+
"""
10+
Configures wildcard_oauth2 as a Django app plugin
11+
"""
12+
13+
name = 'wildcard_oauth2'
14+
verbose_name = "Wildcard OAuth2 Application"
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
A custom OAuth2 application that allows wildcard for redirect_uris
3+
4+
https://github.com/jazzband/django-oauth-toolkit/issues/443#issuecomment-420255286
5+
"""
6+
import re
7+
from urllib.parse import parse_qsl, unquote, urlparse
8+
9+
from django.core.exceptions import ValidationError
10+
from oauth2_provider.models import AbstractApplication
11+
from oauth2_provider.settings import oauth2_settings
12+
13+
14+
def validate_uris(value):
15+
"""Ensure that `value` contains valid blank-separated URIs."""
16+
urls = value.split()
17+
for url in urls:
18+
obj = urlparse(url)
19+
if obj.fragment:
20+
raise ValidationError('Redirect URIs must not contain fragments')
21+
if obj.scheme.lower() not in oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES:
22+
raise ValidationError('Redirect URI scheme is not allowed.')
23+
if not obj.netloc:
24+
raise ValidationError('Redirect URI must contain a domain.')
25+
26+
27+
class Application(AbstractApplication):
28+
"""Subclass of application to allow for regular expressions for the redirect uri."""
29+
30+
@staticmethod
31+
def _uri_is_allowed(allowed_uri, uri):
32+
"""Check that the URI conforms to these rules."""
33+
schemes_match = allowed_uri.scheme == uri.scheme
34+
netloc_matches_pattern = re.fullmatch(allowed_uri.netloc, uri.netloc)
35+
36+
# The original code allowed only fixed paths only with:
37+
# paths_match = allowed_uri.path == uri.path
38+
# However, since paths can contain variable portions (e.g. code-server),
39+
# code was modified to support regex patterns in paths as well.
40+
paths_match = re.fullmatch(allowed_uri.path, uri.path)
41+
42+
return all([schemes_match, netloc_matches_pattern, paths_match])
43+
44+
def __init__(self, *args, **kwargs):
45+
"""Relax the validator to allow for uris with regular expressions."""
46+
self._meta.get_field('redirect_uris').validators = [
47+
validate_uris,
48+
]
49+
super().__init__(*args, **kwargs)
50+
51+
def redirect_uri_allowed(self, uri):
52+
"""
53+
Check if given url is one of the items in :attr:`redirect_uris` string.
54+
A Redirect uri domain may be a regular expression e.g. `^(.*).example.com$` will
55+
match all subdomains of example.com.
56+
A Redirect uri may be `https://(.*).example.com/some/path/?q=x`
57+
:param uri: Url to check
58+
"""
59+
for allowed_uri in self.redirect_uris.split():
60+
parsed_allowed_uri = urlparse(allowed_uri)
61+
parsed_uri = urlparse(uri)
62+
63+
if self._uri_is_allowed(parsed_allowed_uri, parsed_uri):
64+
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
65+
uqs_set = set(parse_qsl(parsed_uri.query))
66+
67+
if aqs_set.issubset(uqs_set):
68+
return True
69+
70+
return False
71+
72+
def clean(self):
73+
uris_with_wildcard = [uri for uri in self.redirect_uris.split(' ') if '*' in uri]
74+
if uris_with_wildcard:
75+
self.redirect_uris = ' '.join(
76+
[uri for uri in self.redirect_uris.split(' ') if '*' not in uri]
77+
)
78+
super().clean()
79+
if uris_with_wildcard:
80+
self.redirect_uris += ' ' + ' '.join(uris_with_wildcard)
81+
82+
def is_usable(self, request):
83+
# This is a hacky way to decode redirect_uri stored in an oauthlib.Request instance.
84+
# Once the oauthlib.Request class started decoding redirect_uri correctly, this will
85+
# be removed.
86+
if getattr(request, '_params'):
87+
redirect_uri = request._params.get('redirect_uri')
88+
if redirect_uri:
89+
request._params['redirect_uri'] = unquote(redirect_uri)
90+
91+
return True
92+
93+
class Meta:
94+
db_table = 'oauth2_provider_application'
95+
# Without the following line, tests fail with:
96+
# RuntimeError: Model class wildcard_oauth2.models.Application doesn't declare
97+
# an explicit app_label and isn't in an application in INSTALLED_APPS.
98+
app_label = 'wildcard_oauth2'

ansible_wisdom/wildcard_oauth2/tests/__init__.py

Whitespace-only changes.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from django.core.exceptions import ValidationError
2+
from django.test import TestCase
3+
4+
from ..models import Application, validate_uris
5+
6+
redirect_uris = [
7+
'vscode://redhat.ansible',
8+
r'https://.*/.*?.*',
9+
r'http://.*/.*?.*',
10+
]
11+
12+
13+
class _AppLabel:
14+
app_label = 'wildcard_oauth2'
15+
16+
17+
class WildcardOAuth2Test(TestCase):
18+
def setUp(self):
19+
self.app = Application(redirect_uris=' '.join(redirect_uris))
20+
21+
def test_standalone_vscode_callback_uri(self):
22+
rc = self.app.redirect_uri_allowed('vscode://redhat.ansible')
23+
self.assertTrue(rc)
24+
self.app.clean()
25+
26+
def test_invalid_callback_uri(self):
27+
rc = self.app.redirect_uri_allowed('vscode://othercompany.ansible')
28+
self.assertFalse(rc)
29+
30+
def test_valid_codespases_callback_uri(self):
31+
rc = self.app.redirect_uri_allowed(
32+
'https://jubilant-engine-wv4w5xw9vq9f9gg9.github.dev/'
33+
'extension-auth-callback?state=6766a56164972ebe9ab0350c00d9041c'
34+
)
35+
self.assertTrue(rc)
36+
37+
def test_valid_code_server_callback_uri(self):
38+
rc = self.app.redirect_uri_allowed(
39+
'http://localhost:18080/stable-9658969084238651b6dde258e04f4abd9b14bfd1/callback'
40+
'?vscode-reqid=2&vscode-scheme=code-oss&vscode-authority=redhat.ansible'
41+
)
42+
self.assertTrue(rc)
43+
44+
45+
class ValidateUrisTest(TestCase):
46+
def test_uri_no_error(self):
47+
validate_uris('https://example.com/callback')
48+
49+
def test_uri_containing_fragment(self):
50+
try:
51+
validate_uris('https://example.com/callback#fragment')
52+
except ValidationError as e:
53+
self.assertEqual(e.message, 'Redirect URIs must not contain fragments')
54+
55+
def test_uri_containing_invalid_scheme(self):
56+
try:
57+
validate_uris('myapp://example.com/callback')
58+
except ValidationError as e:
59+
self.assertEqual(e.message, 'Redirect URI scheme is not allowed.')
60+
61+
def test_uri_containing_no_domain(self):
62+
try:
63+
validate_uris('vscode:redhat.ansible')
64+
except ValidationError as e:
65+
self.assertEqual(e.message, 'Redirect URI must contain a domain.')

0 commit comments

Comments
 (0)