grim/rboidc

Initial revision bascially just configuration at this point
draft default tip
2020-05-23, Gary Kramlich
869a79ae6a70
Parents
Children
Initial revision bascially just configuration at this point
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore Sat May 23 06:01:04 2020 -0500
@@ -0,0 +1,5 @@
+syntax: glob
+*.egg-info/
+*.pyc
+__pycache__
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/rboidc/extension.py Sat May 23 06:01:04 2020 -0500
@@ -0,0 +1,29 @@
+from __future__ import unicode_literals
+
+from django.utils.translation import ugettext_lazy as _
+
+from reviewboard.extensions.base import Extension
+from reviewboard.extensions.hooks import AuthBackendHook, TemplateHook
+
+from rboidc.oidc import OIDCAuthBackend
+
+
+class RbOIDCExtension(Extension):
+ metadata = {
+ 'Name': _('OpenID Connect Authentication'),
+ 'Summary': _('Support for any OIDC compliant issuer'),
+ }
+
+ def initialize(self):
+ AuthBackendHook(self, OIDCAuthBackend)
+
+
+ from django.conf import settings
+
+ settings.grim = True
+
+ TemplateHook(self,
+ name='before-login-form',
+ template_name='oidc-links.html',
+ )
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/rboidc/oidc.py Sat May 23 06:01:04 2020 -0500
@@ -0,0 +1,172 @@
+from __future__ import unicode_literals
+
+import requests
+
+try:
+ # try python3 first
+ from urllib.parse import urljoin
+except:
+ from urlparse import urljoin
+
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+
+from djblets.siteconfig.forms import SiteSettingsForm
+
+from reviewboard.accounts.backends import AuthBackend
+
+
+class OIDCSettingsForm(SiteSettingsForm):
+ """ OIDC Settings Form """
+
+ auth_oidc_issuer_url = forms.CharField(
+ label=_('Issuer URL'),
+ help_text=_('The URL of the OIDC provider.'),
+ required=True,
+ widget=forms.TextInput(attrs={'size': '40'}),
+ )
+
+ auth_oidc_client_id = forms.CharField(
+ label=_('Client ID'),
+ help_text=_('The client ID that the issuer provided you with.'),
+ required=True,
+ widget=forms.TextInput(attrs={'size': '40'}),
+ )
+
+ auth_oidc_client_secret = forms.CharField(
+ label=_('Client Secret'),
+ help_text=_('The client secret that the issuer provided you with.'),
+ required=True,
+ widget=forms.PasswordInput(render_value=True, attrs={'size': '40'}),
+ )
+
+ auth_oidc_scopes = forms.CharField(
+ label=_('Scopes'),
+ help_text=_('A comma separated list scopes that you are requesting '
+ 'from the issuer.'),
+ required=True,
+ widget=forms.TextInput(attrs={'size': '40'}),
+ )
+
+ auth_oidc_authorization_endpoint = forms.CharField(
+ label=_('Authorization Endpoint'),
+ help_text=_('The endpoint to authorize users.'),
+ required=False,
+ widget=forms.TextInput(attrs={'size': '40'}),
+ )
+
+ auth_oidc_token_endpoint = forms.CharField(
+ label=_('Token Endpoint'),
+ help_text=_('The endpoint to request tokens.'),
+ required=False,
+ widget=forms.TextInput(attrs={'size': '40'}),
+ )
+
+ auth_oidc_userinfo_endpoint = forms.CharField(
+ label=_('Userinfo Endpoint'),
+ help_text=_('The endpoint to get information about the user.'),
+ required=False,
+ widget=forms.TextInput(attrs={'size': '40'}),
+ )
+
+ def clean_auth_oidc_issuer_url(self):
+ url = self.cleaned_data.get('auth_oidc_issuer_url')
+
+ # Remove a trailing slash to make it easier to check if we already have
+ # the .well-known path.
+ if url.endswith('/'):
+ url = url[:-1]
+
+ if url.endswith('/.well-known/openid-configuration'):
+ return url
+
+ # Put a / back in case the provider as a path element to it.
+ if not url.endswith('/'):
+ url += '/'
+
+ return urljoin(url, '.well-known/openid-configuration')
+
+ def clean_auth_oidc_scopes(self):
+ scopes = self.cleaned_data.get('auth_oidc_scopes')
+
+ cleaned = [x.strip() for x in scopes.split(',') if x.strip() != '']
+
+ return ','.join(cleaned)
+
+ def clean(self):
+ """ Validate the settings the user provided. """
+
+ self.cleaned_data = super(OIDCSettingsForm, self).clean()
+
+ # Try to discover our endpoints from the endpoints from the issuer url.
+ issuer_url = self.cleaned_data['auth_oidc_issuer_url']
+ resp = requests.get(issuer_url)
+
+ # The OIDC Discovery spec says it *HAS* to respond with a 200 OK.
+ if resp.status_code != 200:
+ raise ValidationError(_('%s responsed with %d' % (issuer_url,
+ resp.status)))
+
+ data = None
+ try:
+ data = resp.json()
+ except:
+ raise ValidationError(_('%s did not return a JSON document' %
+ issuer_url))
+
+ # grab and validate the authorization_endpoint
+ endpoint = data.get('authorization_endpoint', None)
+ if endpoint is None:
+ raise ValidationError(_('%s did not provide an '
+ 'authorization_endpoint' % issuer_url))
+ self.cleaned_data['auth_oidc_authorization_endpoint'] = endpoint
+
+ # grab and validate the token_endpoint
+ endpoint = data.get('token_endpoint', None)
+ if endpoint is None:
+ raise ValidationError(_('%s did not provide a token_endpoint' %
+ issuer_url))
+ self.cleaned_data['auth_oidc_token_endpoint'] = endpoint
+
+ # grab and validate the userinfo_endpoint
+ endpoint = data.get('userinfo_endpoint', None)
+ if endpoint is None:
+ raise ValidationError(_('%s did not provide an userinfo_endpoint' %
+ issuer_url))
+ self.cleaned_data['auth_oidc_userinfo_endpoint'] = endpoint
+
+ # Now make sure all the scopes we're looking for are supported.
+ scopes = self.cleaned_data.get('auth_oidc_scopes')
+ for scope in scopes.split(','):
+ if scope not in data['scopes_supported']:
+ raise ValidationError(_('%s does not support scope %s' %
+ (issuer_url, scope)))
+
+ return self.cleaned_data
+
+ class Meta:
+ title = _('OpenID Connect Settings')
+ fieldsets = (
+ (None, {
+ 'fields': ('auth_oidc_issuer_url', 'auth_oidc_client_id',
+ 'auth_oidc_client_secret', 'auth_oidc_scopes'),
+ }),
+ (_('Discovered Endpoints'), {
+ 'fields': ('auth_oidc_authorization_endpoint',
+ 'auth_oidc_token_endpoint',
+ 'auth_oidc_userinfo_endpoint'),
+ })
+ )
+
+
+class OIDCAuthBackend(AuthBackend):
+ backend_id = 'grim-oidc'
+ name = _('OpenID Connect')
+ settings_form = OIDCSettingsForm
+
+ def authenticate(self, username, password):
+ pass
+
+ def get_or_create_user(self, username, request):
+ pass
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/rboidc/templates/oidc-links.html Sat May 23 06:01:04 2020 -0500
@@ -0,0 +1,3 @@
+<a href="#">Login with OIDC</a>
+<div style="margin-left: 25%; margin-right: 25%; "><hr/></div>
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py Sat May 23 06:01:04 2020 -0500
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+
+from __future__ import unicode_literals
+
+from reviewboard.extensions.packaging import setup
+from setuptools import find_packages
+
+
+setup(
+ name='rboidc',
+ version='0.0.1',
+ author='Gary Kramlich',
+ author_email='grim@reaperworld.com',
+ url='https://keep.imfreedom.org/grim/rb-oidc',
+ description='OpenID Connect for ReviewBoard',
+ packages=find_packages(),
+ install_requires=[],
+ entry_points={
+ 'reviewboard.extensions': [
+ 'rboidc = rboidc.extension:RbOIDCExtension',
+ ],
+ },
+ license='GPLv2',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Web Framework',
+ 'Framework :: Review Board',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
+ ],
+)