diff --git a/src/pretix/base/customersso/oidc.py b/src/pretix/base/customersso/oidc.py
index 5b93b578e..9ad8c383c 100644
--- a/src/pretix/base/customersso/oidc.py
+++ b/src/pretix/base/customersso/oidc.py
@@ -19,17 +19,34 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+import base64
+import hashlib
import logging
+import time
+from datetime import datetime
from urllib.parse import urlencode, urljoin
+import jwt
import requests
+from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
+from cryptography.hazmat.primitives.serialization import (
+ Encoding, NoEncryption, PrivateFormat, PublicFormat,
+)
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from requests import RequestException
+from pretix.multidomain.urlreverse import build_absolute_uri
+
logger = logging.getLogger(__name__)
+"""
+This module contains utilities for implementing OpenID Connect for customer authentication both as a receiving party (RP)
+as well as an OpenID Provider (OP).
+"""
+
+
def _urljoin(base, path):
if not base.endswith("/"):
base += "/"
@@ -205,3 +222,74 @@ def oidc_validate_authorization(provider, code, redirect_uri):
)
return profile
+
+
+def _hash_scheme(value):
+ # As described in https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken
+ digest = hashlib.sha256(value.encode()).digest()
+ digest_truncated = digest[:(len(digest) // 2)]
+ return base64.urlsafe_b64encode(digest_truncated).decode().rstrip("=")
+
+
+def customer_claims(customer, scope):
+ scope = scope.split(' ')
+ claims = {
+ 'sub': customer.identifier,
+ 'locale': customer.locale,
+ }
+ if 'profile' in scope:
+ if customer.name:
+ claims['name'] = customer.name
+ if 'given_name' in customer.name_parts:
+ claims['given_name'] = customer.name_parts['given_name']
+ if 'family_name' in customer.name_parts:
+ claims['family_name'] = customer.name_parts['family_name']
+ if 'middle_name' in customer.name_parts:
+ claims['middle_name'] = customer.name_parts['middle_name']
+ if 'calling_name' in customer.name_parts:
+ claims['nickname'] = customer.name_parts['calling_name']
+ if 'email' in scope and customer.email:
+ claims['email'] = customer.email
+ claims['email_verified'] = customer.is_verified
+ if 'phone' in scope and customer.phone:
+ claims['phone_number'] = customer.phone.as_international
+ return claims
+
+
+def _get_or_create_server_keypair(organizer):
+ if not organizer.settings.sso_server_signing_key_rsa256_private:
+ privkey = generate_private_key(key_size=4096, public_exponent=65537)
+ pubkey = privkey.public_key()
+ organizer.settings.sso_server_signing_key_rsa256_private = privkey.private_bytes(
+ Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
+ ).decode()
+ organizer.settings.sso_server_signing_key_rsa256_public = pubkey.public_bytes(
+ Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
+ ).decode()
+ return organizer.settings.sso_server_signing_key_rsa256_private, organizer.settings.sso_server_signing_key_rsa256_public
+
+
+def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None):
+ payload = {
+ 'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'),
+ 'aud': client.client_id,
+ 'exp': int(expires.timestamp()),
+ 'iat': int(time.time()),
+ 'auth_time': auth_time,
+ **customer_claims(customer, client.evaluated_scope(scope) if scope_claims else ''),
+ }
+ if nonce:
+ payload['nonce'] = nonce
+ if with_code:
+ payload['c_hash'] = _hash_scheme(with_code)
+ if with_access_token:
+ payload['at_hash'] = _hash_scheme(with_access_token)
+ privkey, pubkey = _get_or_create_server_keypair(client.organizer)
+ return jwt.encode(
+ payload,
+ privkey,
+ headers={
+ "kid": hashlib.sha256(pubkey.encode()).hexdigest()[:16]
+ },
+ algorithm="RS256",
+ )
diff --git a/src/pretix/base/migrations/0220_auto_20220811_1002.py b/src/pretix/base/migrations/0220_auto_20220811_1002.py
new file mode 100644
index 000000000..62821f6e7
--- /dev/null
+++ b/src/pretix/base/migrations/0220_auto_20220811_1002.py
@@ -0,0 +1,68 @@
+# Generated by Django 3.2.12 on 2022-08-11 10:02
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import pretix.base.models.base
+import pretix.base.models.customers
+import pretix.base.models.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0219_auto_20220706_0913'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CustomerSSOClient',
+ fields=[
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=255)),
+ ('is_active', models.BooleanField(default=True)),
+ ('client_id', models.CharField(db_index=True, default=pretix.base.models.customers.generate_client_id, max_length=100, unique=True)),
+ ('client_secret', models.CharField(max_length=255)),
+ ('client_type', models.CharField(default='confidential', max_length=32)),
+ ('authorization_grant_type', models.CharField(default='authorization-code', max_length=32)),
+ ('redirect_uris', models.TextField()),
+ ('allowed_scopes', pretix.base.models.fields.MultiStringField(default=['openid', 'profile', 'email', 'phone'])),
+ ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_clients', to='pretixbase.organizer')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=(models.Model, pretix.base.models.base.LoggingMixin),
+ ),
+ migrations.AlterField(
+ model_name='customer',
+ name='provider',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='customers', to='pretixbase.customerssoprovider'),
+ ),
+ migrations.CreateModel(
+ name='CustomerSSOGrant',
+ fields=[
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('code', models.CharField(max_length=255, unique=True)),
+ ('nonce', models.CharField(max_length=255, null=True)),
+ ('auth_time', models.IntegerField()),
+ ('expires', models.DateTimeField()),
+ ('redirect_uri', models.TextField()),
+ ('scope', models.TextField()),
+ ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='pretixbase.customerssoclient')),
+ ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_grants', to='pretixbase.customer')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='CustomerSSOAccessToken',
+ fields=[
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('from_code', models.CharField(max_length=255, null=True)),
+ ('token', models.CharField(max_length=255, unique=True)),
+ ('expires', models.DateTimeField()),
+ ('scope', models.TextField()),
+ ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='pretixbase.customerssoclient')),
+ ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_access_tokens', to='pretixbase.customer')),
+ ],
+ ),
+ ]
diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py
index 630202fef..4df92010a 100644
--- a/src/pretix/base/models/customers.py
+++ b/src/pretix/base/models/customers.py
@@ -24,7 +24,7 @@ from django.conf import settings
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
)
-from django.core.validators import RegexValidator
+from django.core.validators import RegexValidator, URLValidator
from django.db import models
from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
@@ -35,6 +35,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
+from pretix.base.models.fields import MultiStringField
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
@@ -348,3 +349,134 @@ class AttendeeProfile(models.Model):
parts.append(f'{a["field_label"]}: {val}')
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
+
+
+def generate_client_id():
+ return get_random_string(40)
+
+
+def generate_client_secret():
+ return get_random_string(40)
+
+
+class CustomerSSOClient(LoggedModel):
+ CLIENT_CONFIDENTIAL = "confidential"
+ CLIENT_PUBLIC = "public"
+ CLIENT_TYPES = (
+ (CLIENT_CONFIDENTIAL, pgettext_lazy("openidconnect", "Confidential")),
+ (CLIENT_PUBLIC, pgettext_lazy("openidconnect", "Public")),
+ )
+
+ GRANT_AUTHORIZATION_CODE = "authorization-code"
+ GRANT_IMPLICIT = "implicit"
+ GRANT_TYPES = (
+ (GRANT_AUTHORIZATION_CODE, pgettext_lazy("openidconnect", "Authorization code")),
+ (GRANT_IMPLICIT, pgettext_lazy("openidconnect", "Implicit")),
+ )
+
+ SCOPE_CHOICES = (
+ ('openid', _('OpenID Connect access (required)')),
+ ('profile', _('Profile data (name, addresses)')),
+ ('email', _('E-mail address')),
+ ('phone', _('Phone number')),
+ )
+
+ id = models.BigAutoField(primary_key=True)
+ organizer = models.ForeignKey(Organizer, related_name='sso_clients', on_delete=models.CASCADE)
+
+ name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
+ is_active = models.BooleanField(default=True, verbose_name=_('Active'))
+
+ client_id = models.CharField(
+ verbose_name=_("Client ID"),
+ max_length=100, unique=True, default=generate_client_id, db_index=True
+ )
+ client_secret = models.CharField(
+ max_length=255, blank=False,
+ )
+
+ client_type = models.CharField(
+ max_length=32, choices=CLIENT_TYPES, verbose_name=_("Client type"), default=CLIENT_CONFIDENTIAL,
+ )
+ authorization_grant_type = models.CharField(
+ max_length=32, choices=GRANT_TYPES, verbose_name=_("Grant type"), default=GRANT_AUTHORIZATION_CODE,
+ )
+ redirect_uris = models.TextField(
+ blank=False,
+ verbose_name=_("Redirection URIs"),
+ help_text=_("Allowed URIs list, space separated")
+ )
+ allowed_scopes = MultiStringField(
+ default=['openid', 'profile', 'email', 'phone'],
+ delimiter=" ",
+ blank=True,
+ verbose_name=_('Allowed access scopes'),
+ help_text=_('Separate multiple values with spaces'),
+ )
+
+ def is_usable(self):
+ return self.is_active
+
+ def allow_redirect_uri(self, redirect_uri):
+ return self.redirect_uris and any(r.strip() == redirect_uri for r in self.redirect_uris.split(' '))
+
+ def allow_delete(self):
+ return True
+
+ def evaluated_scope(self, scope):
+ scope = set(scope.split(' '))
+ allowed_scopes = set(self.allowed_scopes)
+ return ' '.join(scope & allowed_scopes)
+
+ def clean(self):
+ redirect_uris = self.redirect_uris.strip().split()
+
+ if redirect_uris:
+ validator = URLValidator()
+ for uri in redirect_uris:
+ validator(uri)
+
+ def set_client_secret(self):
+ secret = get_random_string(64)
+ self.client_secret = make_password(secret)
+ return secret
+
+ def check_client_secret(self, raw_secret):
+ """
+ Return a boolean of whether the ra_secret was correct. Handles
+ hashing formats behind the scenes.
+ """
+ def setter(raw_secret):
+ self.client_secret = make_password(raw_secret)
+ self.save(update_fields=["client_secret"])
+ return check_password(raw_secret, self.client_secret, setter)
+
+
+class CustomerSSOGrant(models.Model):
+ id = models.BigAutoField(primary_key=True)
+ client = models.ForeignKey(
+ CustomerSSOClient, on_delete=models.CASCADE, related_name="grants"
+ )
+ customer = models.ForeignKey(
+ Customer, on_delete=models.CASCADE, related_name="sso_grants"
+ )
+ code = models.CharField(max_length=255, unique=True)
+ nonce = models.CharField(max_length=255, null=True, blank=True)
+ auth_time = models.IntegerField()
+ expires = models.DateTimeField()
+ redirect_uri = models.TextField()
+ scope = models.TextField(blank=True)
+
+
+class CustomerSSOAccessToken(models.Model):
+ id = models.BigAutoField(primary_key=True)
+ client = models.ForeignKey(
+ CustomerSSOClient, on_delete=models.CASCADE, related_name="access_tokens"
+ )
+ customer = models.ForeignKey(
+ Customer, on_delete=models.CASCADE, related_name="sso_access_tokens"
+ )
+ from_code = models.CharField(max_length=255, null=True, blank=True)
+ token = models.CharField(max_length=255, unique=True)
+ expires = models.DateTimeField()
+ scope = models.TextField(blank=True)
diff --git a/src/pretix/base/models/fields.py b/src/pretix/base/models/fields.py
index 9fc371111..218a7c668 100644
--- a/src/pretix/base/models/fields.py
+++ b/src/pretix/base/models/fields.py
@@ -33,7 +33,8 @@ class MultiStringField(TextField):
'delimiter_found': _('No value can contain the delimiter character.')
}
- def __init__(self, verbose_name=None, name=None, **kwargs):
+ def __init__(self, verbose_name=None, name=None, delimiter=DELIMITER, **kwargs):
+ self.delimiter = delimiter
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
@@ -44,13 +45,13 @@ class MultiStringField(TextField):
if isinstance(value, (list, tuple)):
return value
elif value:
- return [v for v in value.split(DELIMITER) if v]
+ return [v for v in value.split(self.delimiter) if v]
else:
return []
def get_prep_value(self, value):
if isinstance(value, (list, tuple)):
- return DELIMITER + DELIMITER.join(value) + DELIMITER
+ return self.delimiter + self.delimiter.join(value) + self.delimiter
elif value is None:
if self.null:
return None
@@ -63,14 +64,14 @@ class MultiStringField(TextField):
def from_db_value(self, value, expression, connection):
if value:
- return [v for v in value.split(DELIMITER) if v]
+ return [v for v in value.split(self.delimiter) if v]
else:
return []
def validate(self, value, model_instance):
super().validate(value, model_instance)
for l in value:
- if DELIMITER in l:
+ if self.delimiter in l:
raise exceptions.ValidationError(
self.error_messages['delimiter_found'],
code='delimiter_found',
@@ -78,9 +79,9 @@ class MultiStringField(TextField):
def get_lookup(self, lookup_name):
if lookup_name == 'contains':
- return MultiStringContains
+ return make_multistring_contains_lookup(self.delimiter)
elif lookup_name == 'icontains':
- return MultiStringIContains
+ return make_multistring_icontains_lookup(self.delimiter)
elif lookup_name == 'isnull':
return builtin_lookups.IsNull
raise NotImplementedError(
@@ -88,18 +89,22 @@ class MultiStringField(TextField):
)
-class MultiStringContains(builtin_lookups.Contains):
- def process_rhs(self, qn, connection):
- sql, params = super().process_rhs(qn, connection)
- params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
- return sql, params
+def make_multistring_contains_lookup(delimiter):
+ class Cls(builtin_lookups.Contains):
+ def process_rhs(self, qn, connection):
+ sql, params = super().process_rhs(qn, connection)
+ params[0] = "%" + delimiter + params[0][1:-1] + delimiter + "%"
+ return sql, params
+ return Cls
-class MultiStringIContains(builtin_lookups.IContains):
- def process_rhs(self, qn, connection):
- sql, params = super().process_rhs(qn, connection)
- params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
- return sql, params
+def make_multistring_icontains_lookup(delimiter):
+ class Cls(builtin_lookups.IContains):
+ def process_rhs(self, qn, connection):
+ sql, params = super().process_rhs(qn, connection)
+ params[0] = "%" + delimiter + params[0][1:-1] + delimiter + "%"
+ return sql, params
+ return Cls
class MultiStringSerializer(serializers.Field):
diff --git a/src/pretix/base/services/cleanup.py b/src/pretix/base/services/cleanup.py
index 06fc89539..1adee2f62 100644
--- a/src/pretix/base/services/cleanup.py
+++ b/src/pretix/base/services/cleanup.py
@@ -28,6 +28,7 @@ from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket
+from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..signals import periodic_task
@@ -68,3 +69,9 @@ def clean_cached_tickets(sender, **kwargs):
@scopes_disabled()
def clearsessions(sender, **kwargs):
call_command('clearsessions')
+
+
+@receiver(signal=periodic_task)
+@scopes_disabled()
+def clear_oidc_data(sender, **kwargs):
+ CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 87859d947..f925ae28f 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -62,7 +62,7 @@ from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
)
-from pretix.base.models.customers import CustomerSSOProvider
+from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.control.forms import ExtFileField, SplitDateTimeField
@@ -797,3 +797,36 @@ class SSOProviderForm(I18nModelForm):
oidc_validate_and_complete_config(config)
self.instance.configuration = config
+
+
+class SSOClientForm(I18nModelForm):
+ regenerate_client_secret = forms.BooleanField(
+ label=_('Invalidate old client secret and generate a new one'),
+ required=False,
+ )
+
+ class Meta:
+ model = CustomerSSOClient
+ fields = ['is_active', 'name', 'client_id', 'client_type', 'authorization_grant_type', 'redirect_uris',
+ 'allowed_scopes']
+ widgets = {
+ 'authorization_grant_type': forms.RadioSelect,
+ 'client_type': forms.RadioSelect,
+ 'allowed_scopes': forms.CheckboxSelectMultiple,
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['allowed_scopes'] = forms.MultipleChoiceField(
+ label=self.fields['allowed_scopes'].label,
+ help_text=self.fields['allowed_scopes'].help_text,
+ required=self.fields['allowed_scopes'].required,
+ initial=self.fields['allowed_scopes'].initial,
+ choices=CustomerSSOClient.SCOPE_CHOICES,
+ widget=forms.CheckboxSelectMultiple
+ )
+ if self.instance and self.instance.pk:
+ self.fields['client_id'].disabled = True
+ else:
+ del self.fields['client_id']
+ del self.fields['regenerate_client_secret']
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 9ad70d25a..118415ead 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -324,6 +324,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.ssoprovider.created': _('The SSO provider has been created.'),
'pretix.ssoprovider.changed': _('The SSO provider has been changed.'),
'pretix.ssoprovider.deleted': _('The SSO provider has been deleted.'),
+ 'pretix.ssoclient.created': _('The SSO client has been created.'),
+ 'pretix.ssoclient.changed': _('The SSO client has been changed.'),
+ 'pretix.ssoclient.deleted': _('The SSO client has been deleted.'),
'pretix.membershiptype.created': _('The membership type has been created.'),
'pretix.membershiptype.changed': _('The membership type has been changed.'),
'pretix.membershiptype.deleted': _('The membership type has been deleted.'),
diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py
index e7fdbbe9a..b40fab0f8 100644
--- a/src/pretix/control/navigation.py
+++ b/src/pretix/control/navigation.py
@@ -550,6 +550,15 @@ def get_organizer_navigation(request):
'active': 'organizer.membershiptype' in url.url_name,
}
)
+ children.append(
+ {
+ 'label': _('SSO clients'),
+ 'url': reverse('control:organizer.ssoclients', kwargs={
+ 'organizer': request.organizer.slug
+ }),
+ 'active': 'organizer.ssoclient' in url.url_name,
+ }
+ )
children.append(
{
'label': _('SSO providers'),
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html
new file mode 100644
index 000000000..8be5bbf52
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html
@@ -0,0 +1,25 @@
+{% extends "pretixcontrol/organizers/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block inner %}
+
{% trans "Delete SSO client:" %} {{ client.name }}
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_edit.html
new file mode 100644
index 000000000..e1265ced3
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoclient_edit.html
@@ -0,0 +1,20 @@
+{% extends "pretixcontrol/organizers/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block inner %}
+ {% if client %}
+ {% trans "SSO client:" %} {{ client.name }}
+ {% else %}
+ {% trans "Create a new SSO client" %}
+ {% endif %}
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/ssoclients.html b/src/pretix/control/templates/pretixcontrol/organizers/ssoclients.html
new file mode 100644
index 000000000..b9eec0417
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/organizers/ssoclients.html
@@ -0,0 +1,44 @@
+{% extends "pretixcontrol/organizers/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "SSO clients" %}{% endblock %}
+{% block inner %}
+ {% trans "SSO clients" %}
+
+ {% blocktrans trimmed %}
+ You can allow your customers to log into other systems using their customer account credentials by setting up
+ your other systems as a Single-Sign-On (SSO) client based on OpenID Connect.
+ {% endblocktrans %}
+
+
+
+ {% trans "Create a new SSO client" %}
+
+
+{% endblock %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index a14131f45..32ea9b9d9 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -140,6 +140,13 @@ urlpatterns = [
name='organizer.ssoprovider.edit'),
re_path(r'^organizer/(?P[^/]+)/ssoprovider/(?P[^/]+)/delete$', organizer.SSOProviderDeleteView.as_view(),
name='organizer.ssoprovider.delete'),
+ re_path(r'^organizer/(?P[^/]+)/ssoclients$', organizer.SSOClientListView.as_view(), name='organizer.ssoclients'),
+ re_path(r'^organizer/(?P[^/]+)/ssoclient/add$', organizer.SSOClientCreateView.as_view(),
+ name='organizer.ssoclient.add'),
+ re_path(r'^organizer/(?P[^/]+)/ssoclient/(?P[^/]+)/edit$', organizer.SSOClientUpdateView.as_view(),
+ name='organizer.ssoclient.edit'),
+ re_path(r'^organizer/(?P[^/]+)/ssoclient/(?P[^/]+)/delete$', organizer.SSOClientDeleteView.as_view(),
+ name='organizer.ssoclient.delete'),
re_path(r'^organizer/(?P[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
re_path(r'^organizer/(?P[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
re_path(r'^organizer/(?P[^/]+)/customer/add$',
diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py
index a21a8e300..d665b0949 100644
--- a/src/pretix/control/views/organizer.py
+++ b/src/pretix/control/views/organizer.py
@@ -71,7 +71,7 @@ from pretix.base.models import (
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
Team, TeamInvite, User,
)
-from pretix.base.models.customers import CustomerSSOProvider
+from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
from pretix.base.models.giftcards import (
GiftCardTransaction, gen_giftcard_secret,
@@ -95,8 +95,8 @@ from pretix.control.forms.organizer import (
EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
- OrganizerSettingsForm, OrganizerUpdateForm, SSOProviderForm, TeamForm,
- WebHookForm,
+ OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm,
+ TeamForm, WebHookForm,
)
from pretix.control.logdisplay import OVERVIEW_BANLIST
from pretix.control.permissions import (
@@ -2039,6 +2039,134 @@ class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequire
return redirect(success_url)
+class SSOClientListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
+ model = CustomerSSOClient
+ template_name = 'pretixcontrol/organizers/ssoclients.html'
+ permission = 'can_change_organizer_settings'
+ context_object_name = 'clients'
+
+ def get_queryset(self):
+ return self.request.organizer.sso_clients.all()
+
+
+class SSOClientCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
+ model = CustomerSSOClient
+ template_name = 'pretixcontrol/organizers/ssoclient_edit.html'
+ permission = 'can_change_organizer_settings'
+ form_class = SSOClientForm
+
+ def get_object(self, queryset=None):
+ return get_object_or_404(CustomerSSOClient, organizer=self.request.organizer, pk=self.kwargs.get('client'))
+
+ def get_success_url(self):
+ return reverse('control:organizer.ssoclient.edit', kwargs={
+ 'organizer': self.request.organizer.slug,
+ 'client': self.object.pk
+ })
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['event'] = self.request.organizer
+ return kwargs
+
+ def form_valid(self, form):
+ secret = form.instance.set_client_secret()
+ messages.success(
+ self.request,
+ _('The SSO client has been created. Please note down the following client secret, it will never be shown '
+ 'again: {secret}').format(secret=secret)
+ )
+ form.instance.organizer = self.request.organizer
+ ret = super().form_valid(form)
+ form.instance.log_action('pretix.ssoclient.created', user=self.request.user, data={
+ k: getattr(self.object, k, form.cleaned_data.get(k)) for k in form.changed_data
+ })
+ return ret
+
+ def form_invalid(self, form):
+ messages.error(self.request, _('Your changes could not be saved.'))
+ return super().form_invalid(form)
+
+
+class SSOClientUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
+ model = CustomerSSOClient
+ template_name = 'pretixcontrol/organizers/ssoclient_edit.html'
+ permission = 'can_change_organizer_settings'
+ context_object_name = 'client'
+ form_class = SSOClientForm
+
+ def get_object(self, queryset=None):
+ return get_object_or_404(CustomerSSOClient, organizer=self.request.organizer, pk=self.kwargs.get('client'))
+
+ def get_success_url(self):
+ return reverse('control:organizer.ssoclient.edit', kwargs={
+ 'organizer': self.request.organizer.slug,
+ 'client': self.object.pk
+ })
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ return ctx
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['event'] = self.request.organizer
+ return kwargs
+
+ def form_valid(self, form):
+ if form.has_changed():
+ self.object.log_action('pretix.ssoclient.changed', user=self.request.user, data={
+ k: getattr(self.object, k, form.cleaned_data.get(k)) for k in form.changed_data
+ })
+ if form.cleaned_data.get('regenerate_client_secret'):
+ secret = form.instance.set_client_secret()
+ messages.success(
+ self.request,
+ _('Your changes have been saved. Please note down the following client secret, it will never be shown '
+ 'again: {secret}').format(secret=secret)
+ )
+ else:
+ messages.success(
+ self.request,
+ _('Your changes have been saved.')
+ )
+ return super().form_valid(form)
+
+ def form_invalid(self, form):
+ messages.error(self.request, _('Your changes could not be saved.'))
+ return super().form_invalid(form)
+
+
+class SSOClientDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
+ model = CustomerSSOClient
+ template_name = 'pretixcontrol/organizers/ssoclient_delete.html'
+ permission = 'can_change_organizer_settings'
+ context_object_name = 'client'
+
+ def get_object(self, queryset=None):
+ return get_object_or_404(CustomerSSOClient, organizer=self.request.organizer, pk=self.kwargs.get('client'))
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['is_allowed'] = self.object.allow_delete()
+ return ctx
+
+ def get_success_url(self):
+ return reverse('control:organizer.ssoclients', kwargs={
+ 'organizer': self.request.organizer.slug,
+ })
+
+ @transaction.atomic
+ def delete(self, request, *args, **kwargs):
+ success_url = self.get_success_url()
+ self.object = self.get_object()
+ if self.object.allow_delete():
+ self.object.log_action('pretix.ssoclient.deleted', user=self.request.user)
+ self.object.delete()
+ messages.success(request, _('The selected object has been deleted.'))
+ return redirect(success_url)
+
+
class CustomerListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
model = Customer
template_name = 'pretixcontrol/organizers/customers.html'
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html
index e59c3c94e..9025d1e19 100644
--- a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html
+++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html
@@ -22,7 +22,7 @@
{% endif %}
- {% for provider in request.organizer.sso_providers.all %}
+ {% for provider in providers %}
{% if provider.is_active %}
diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py
index f2a916a50..0a63c6170 100644
--- a/src/pretix/presale/urls.py
+++ b/src/pretix/presale/urls.py
@@ -40,6 +40,7 @@ import pretix.presale.views.checkout
import pretix.presale.views.customer
import pretix.presale.views.event
import pretix.presale.views.locale
+import pretix.presale.views.oidc_op
import pretix.presale.views.order
import pretix.presale.views.organizer
import pretix.presale.views.robots
@@ -72,6 +73,7 @@ frame_wrapped_urls = [
re_path(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
re_path(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
]
+
event_patterns = [
# Cart/checkout patterns are a bit more complicated, as they should have simple URLs like cart/clear in normal
# cases, but need to have versions with unguessable URLs like w/8l4Y83XNonjLxoBb/cart/clear to be used in widget
@@ -174,9 +176,11 @@ organizer_patterns = [
re_path(r'^events/ical/$',
pretix.presale.views.organizer.OrganizerIcalDownload.as_view(),
name='organizer.ical'),
+
re_path(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
name='organizer.widget.productlist'),
re_path(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
+
re_path(r'^account/login/(?P[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'),
re_path(r'^account/login/(?P[0-9]+)/return$', pretix.presale.views.customer.SSOLoginReturnView.as_view(), name='organizer.customer.login.return'),
re_path(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
@@ -192,6 +196,17 @@ organizer_patterns = [
re_path(r'^account/addresses/(?P\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'),
re_path(r'^account/profiles/(?P\d+)/delete$', pretix.presale.views.customer.ProfileDeleteView.as_view(), name='organizer.customer.profile.delete'),
re_path(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'),
+
+ re_path(r'^oauth2/v1/authorize$', pretix.presale.views.oidc_op.AuthorizeView.as_view(),
+ name='organizer.oauth2.v1.authorize'),
+ re_path(r'^oauth2/v1/token$', pretix.presale.views.oidc_op.TokenView.as_view(),
+ name='organizer.oauth2.v1.token'),
+ re_path(r'^oauth2/v1/userinfo$', pretix.presale.views.oidc_op.UserInfoView.as_view(),
+ name='organizer.oauth2.v1.userinfo'),
+ re_path(r'^oauth2/v1/keys$', pretix.presale.views.oidc_op.KeysView.as_view(),
+ name='organizer.oauth2.v1.jwks'),
+ re_path(r'^.well-known/openid-configuration$', pretix.presale.views.oidc_op.ConfigurationView.as_view(),
+ name='organizer.oauth2.v1.configuration'),
]
locale_patterns = [
diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py
index dd5939f26..959d08b97 100644
--- a/src/pretix/presale/utils.py
+++ b/src/pretix/presale/utils.py
@@ -32,6 +32,7 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import re
+import time
import warnings
from importlib import import_module
from urllib.parse import urljoin
@@ -153,9 +154,15 @@ def add_customer_to_request(request):
request.customer = SimpleLazyObject(lambda: get_customer(request))
+def get_customer_auth_time(request):
+ auth_time_session_key = f'customer_auth_time:{request.organizer.pk}'
+ return request.session.get(auth_time_session_key) or 0
+
+
def customer_login(request, customer):
session_key = f'customer_auth_id:{request.organizer.pk}'
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
+ auth_time_session_key = f'customer_auth_time:{request.organizer.pk}'
dependency_key = f'customer_auth_session_dependency:{request.organizer.pk}'
session_auth_hash = customer.get_session_auth_hash()
@@ -172,6 +179,7 @@ def customer_login(request, customer):
request.session.pop(dependency_key, None)
request.session[session_key] = customer.pk
request.session[hash_session_key] = session_auth_hash
+ request.session[auth_time_session_key] = int(time.time())
request.customer = customer
customer.last_login = now()
@@ -183,6 +191,7 @@ def customer_login(request, customer):
def customer_logout(request):
session_key = f'customer_auth_id:{request.organizer.pk}'
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
+ auth_time_session_key = f'customer_auth_time:{request.organizer.pk}'
dependency_key = f'customer_auth_session_dependency:{request.organizer.pk}'
# Remove dependency on parent session
@@ -193,6 +202,7 @@ def customer_logout(request):
# Remove user session
customer_id = request.session.pop(session_key, None)
request.session.pop(hash_session_key, None)
+ request.session.pop(auth_time_session_key, None)
# Remove carts tied to this user
carts = request.session.get('carts', {})
diff --git a/src/pretix/presale/views/customer.py b/src/pretix/presale/views/customer.py
index 74c200fe6..fce373932 100644
--- a/src/pretix/presale/views/customer.py
+++ b/src/pretix/presale/views/customer.py
@@ -113,6 +113,12 @@ class LoginView(RedirectBackMixin, FormView):
raise Http404('Feature not enabled')
return super().post(request, *args, **kwargs)
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(
+ **kwargs,
+ providers=self.request.organizer.sso_providers.all()
+ )
+
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
diff --git a/src/pretix/presale/views/oidc_op.py b/src/pretix/presale/views/oidc_op.py
new file mode 100644
index 000000000..d8a8401c2
--- /dev/null
+++ b/src/pretix/presale/views/oidc_op.py
@@ -0,0 +1,526 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import base64
+import hashlib
+import time
+from binascii import unhexlify
+from datetime import timedelta
+from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
+
+from Crypto.PublicKey import RSA
+from django.db import transaction
+from django.http import Http404, HttpResponse, JsonResponse
+from django.middleware.csrf import _compare_masked_tokens
+from django.shortcuts import redirect, render
+from django.utils.crypto import get_random_string
+from django.utils.decorators import method_decorator
+from django.utils.timezone import now
+from django.views import View
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.debug import sensitive_post_parameters
+
+from pretix.base.customersso.oidc import (
+ _get_or_create_server_keypair, customer_claims, generate_id_token,
+)
+from pretix.base.models.customers import (
+ CustomerSSOAccessToken, CustomerSSOClient, CustomerSSOGrant,
+)
+from pretix.multidomain.middlewares import CsrfViewMiddleware
+from pretix.multidomain.urlreverse import build_absolute_uri
+from pretix.presale.forms.customer import AuthenticationForm
+from pretix.presale.utils import customer_login, get_customer_auth_time
+
+"""
+We implement the OpenID Connect spec as per https://openid.net/specs/openid-connect-core-1_0.html
+Based on the OAuth spec as per https://www.rfc-editor.org/rfc/rfc6749
+
+We implement all three flows (authorization code, implicit, hybrid), as well as some typical standard
+claims.
+
+We currently do not implement the following optional parts of the spec:
+
+- 4. Initiating Login from a Third Party
+- 5.5. Requesting Claims using the "claims" Request Parameter
+- 5.6.2. Aggregated and Distributed Claims
+- 6. Passing Request Parameters as JWTs
+- 8.1. Pairwise Identifier Algorithm
+- 9. Client Authentication (except for client_secret_basic, client_secret_post)
+- 10.2. Encryption
+- 11. Offline Access
+- 12. Using Refresh Tokens
+
+We also implement the Discovery extension (without issuer discovery)
+as per https://openid.net/specs/openid-connect-discovery-1_0.html
+
+The implementation passed the certification tests against the following profiles, but we did not
+acquire formal certification:
+
+- Basic OP
+- Implicit OP
+- Hybrid OP
+- Config OP
+"""
+
+RESPONSE_TYPES_SUPPORTED = ("code", "id_token token", "id_token", "code id_token", "code id_token token", "code token")
+
+
+class AuthorizeView(View):
+
+ # We need to be exempt from CSRF because the spec mandates that relying parties can send requests as POST.
+ # This is not a risk when we show a login form, because CSRF is pointless for a login form, if the attacker has
+ # the password, they don't need to resort to CSRF. We still to a minimal validation below.
+ # It would be a problem for a consent form, but we currently never show a consent form because it is not required
+ # for our intended use case where all relying parties are at least somewhat trusted.
+ @method_decorator(csrf_exempt)
+ @method_decorator(never_cache)
+ @method_decorator(sensitive_post_parameters())
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
+ return super().dispatch(request, *args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ return self._process_auth_request(request, request.GET)
+
+ def post(self, request, *args, **kwargs):
+ request_token = CsrfViewMiddleware(lambda: None)._get_token(request)
+ if not request_token or not _compare_masked_tokens(request.POST.get('csrfmiddlewaretoken', ''), request_token):
+ # External request, we prefer GET and will redirect to prevent confusion with our login form
+ return redirect(request.path + '?' + request.POST.urlencode())
+ return self._process_auth_request(request, request.GET)
+
+ def _final_error(self, error, error_description):
+ return HttpResponse(
+ f'Error: {error_description} ({error})',
+ status=400,
+ )
+
+ def _construct_redirect_uri(self, redirect_uri, response_mode, params):
+ ru = urlparse(redirect_uri)
+ qs = parse_qs(ru.query)
+ fm = parse_qs(ru.fragment)
+ if response_mode == 'query':
+ qs.update(params)
+ elif response_mode == 'fragment':
+ fm.update(params)
+ query = urlencode(qs, doseq=True)
+ fragment = urlencode(fm, doseq=True)
+ return urlunparse((ru.scheme, ru.netloc, ru.path, ru.params, query, fragment))
+
+ def _redirect_error(self, error, error_description, redirect_uri, response_mode, state):
+ qs = {'error': error, 'error_description': error_description}
+ if state:
+ qs['state'] = state
+ return redirect(
+ self._construct_redirect_uri(redirect_uri, response_mode, qs)
+ )
+
+ def _require_login(self, request, client, scope, redirect_uri, response_type, response_mode, state, nonce):
+ form = AuthenticationForm(data=request.POST if "login-email" in request.POST else None, request=request,
+ prefix="login")
+ if "login-email" in request.POST and form.is_valid():
+ customer_login(request, form.get_customer())
+ return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, form.get_customer())
+ else:
+ return render(request, 'pretixpresale/organizers/customer_login.html', {
+ 'providers': [],
+ 'form': form,
+ })
+
+ def _success(self, client, scope, redirect_uri, response_type, response_mode, state, nonce, customer):
+ response_type = response_type.split(' ')
+ qs = {}
+ id_token_kwargs = {}
+
+ if 'code' in response_type:
+ grant = client.grants.create(
+ customer=customer,
+ scope=' '.join(scope),
+ redirect_uri=redirect_uri,
+ code=get_random_string(64),
+ expires=now() + timedelta(minutes=10),
+ auth_time=get_customer_auth_time(self.request),
+ nonce=nonce,
+ )
+ qs['code'] = grant.code
+ id_token_kwargs['with_code'] = grant.code
+
+ expires = now() + timedelta(hours=24)
+
+ if 'token' in response_type:
+ token = client.access_tokens.create(
+ customer=customer,
+ token=get_random_string(128),
+ expires=expires,
+ scope=' '.join(scope),
+ )
+ qs['access_token'] = token.token
+ qs['token_type'] = 'Bearer'
+ qs['expires_in'] = int((token.expires - now()).total_seconds())
+ id_token_kwargs['with_access_token'] = token.token
+
+ if 'id_token' in response_type:
+ qs['id_token'] = generate_id_token(
+ customer,
+ client,
+ get_customer_auth_time(self.request),
+ nonce,
+ ' '.join(scope),
+ expires,
+ scope_claims='token' not in response_type and 'code' not in response_type,
+ **id_token_kwargs,
+ )
+
+ if state:
+ qs['state'] = state
+
+ r = redirect(self._construct_redirect_uri(redirect_uri, response_mode, qs))
+ r['Cache-Control'] = 'no-store'
+ r['Pragma'] = 'no-cache'
+ return r
+
+ def _process_auth_request(self, request, request_data):
+ response_mode = request_data.get("response_mode")
+ client_id = request_data.get("client_id")
+ state = request_data.get("state")
+ nonce = request_data.get("nonce")
+ max_age = request_data.get("max_age")
+ prompt = request_data.get("prompt")
+ response_type = request_data.get("response_type")
+ scope = request_data.get("scope", "").split(" ")
+
+ if not client_id:
+ return self._final_error("invalid_request", "client_id missing")
+
+ try:
+ client = self.request.organizer.sso_clients.get(is_active=True, client_id=client_id)
+ except CustomerSSOClient.DoesNotExist:
+ return self._final_error("unauthorized_client", "invalid client_id")
+
+ redirect_uri = request_data.get("redirect_uri")
+ if not redirect_uri or not client.allow_redirect_uri(redirect_uri):
+ return self._final_error("invalid_request_uri", "invalid redirect_uri")
+
+ if response_type not in RESPONSE_TYPES_SUPPORTED:
+ return self._final_error("unsupported_response_type", "response_type unsupported")
+
+ if response_type != "code" and response_mode == "query":
+ return self._final_error("invalid_request", "response_mode query must not be used with implicit or hybrid flow")
+ elif not response_mode:
+ response_mode = "query" if response_type == "code" else "fragment"
+ elif response_mode not in ("query", "fragment"):
+ return self._final_error("invalid_request", "invalid response_mode")
+
+ if "request" in request_data:
+ return self._redirect_error("request_not_supported", "request_not_supported", redirect_uri, response_mode, state)
+
+ if response_type not in ("code", "code token") and not nonce:
+ return self._redirect_error("invalid_request", "nonce is required in implicit or hybrid flow", redirect_uri,
+ response_mode, state)
+
+ if "openid" not in scope:
+ return self._redirect_error("invalid_scope", "scope 'openid' must be requested", redirect_uri,
+ response_mode, state)
+
+ if "id_token_hint" in request_data:
+ self._redirect_error("invalid_request", "id_token_hint currently not supported by this server",
+ redirect_uri, response_mode, state)
+
+ has_valid_session = bool(request.customer)
+ if has_valid_session and max_age:
+ try:
+ has_valid_session = int(time.time() - get_customer_auth_time(request)) < int(max_age)
+ except ValueError:
+ self._redirect_error("invalid_request", "invalid max_age value", redirect_uri, response_mode, state)
+
+ if not has_valid_session and prompt and prompt == "none":
+ return self._redirect_error("interaction_required", "user is not logged in but no prompt is allowed",
+ redirect_uri, response_mode, state)
+ elif prompt in ("select_account", "login"):
+ has_valid_session = False
+
+ if has_valid_session:
+ return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, request.customer)
+ else:
+ return self._require_login(request, client, scope, redirect_uri, response_type, response_mode, state, nonce)
+
+
+class TokenView(View):
+ @method_decorator(csrf_exempt)
+ @method_decorator(never_cache)
+ @method_decorator(sensitive_post_parameters())
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
+ return super().dispatch(request, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ auth_header = request.headers.get('Authorization')
+ if auth_header:
+ encoded_credentials = auth_header.split(' ')[1] # Removes "Basic " to isolate credentials
+ decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8").split(':')
+ client_id = decoded_credentials[0]
+ client_secret = decoded_credentials[1]
+ try:
+ self.client = request.organizer.sso_clients.get(client_id=client_id, is_active=True)
+ except CustomerSSOClient.DoesNotExist:
+ return JsonResponse({
+ "error": "invalid_client",
+ "error_description": "Unknown or inactive client_id"
+ }, status=401, headers={
+ 'WWW-Authenticate': 'error="invalid_client"&error_description="Unknown or inactive client_id"'
+ })
+ if not self.client.check_client_secret(client_secret):
+ return JsonResponse({
+ "error": "invalid_client",
+ "error_description": "Wrong client_secret"
+ }, status=401, headers={
+ 'WWW-Authenticate': 'error="invalid_client"&error_description="Wrong client_secret"'
+ })
+ elif request.POST.get("client_id"):
+ try:
+ self.client = request.organizer.sso_clients.get(client_id=request.POST["client_id"], is_active=True)
+ except CustomerSSOClient.DoesNotExist:
+ return JsonResponse({
+ "error": "invalid_client",
+ "error_description": "Unknown or inactive client_id"
+ }, status=400)
+ if "client_secret" in request.POST:
+ if not self.client.check_client_secret(request.POST.get("client_secret")):
+ return JsonResponse({
+ "error": "invalid_client",
+ "error_description": "Wrong client_secret"
+ }, status=401, headers={
+ 'WWW-Authenticate': 'error="invalid_client"&error_description="Wrong client_secret"'
+ })
+ elif self.client.client_type != CustomerSSOClient.CLIENT_PUBLIC:
+ return JsonResponse({
+ "error": "invalid_client",
+ "error_description": "Client is confidential, authentication required"
+ }, status=400)
+ else:
+ return JsonResponse({
+ "error": "invalid_client",
+ "error_description": "Client is confidential, authentication required"
+ }, status=400)
+
+ grant_type = request.POST.get("grant_type")
+ if grant_type == "authorization_code":
+ return self._handle_authorization_code(request)
+ else:
+ return JsonResponse({
+ "error": "unsupported_grant_type"
+ }, status=400)
+
+ def _handle_authorization_code(self, request):
+ code = request.POST.get("code")
+ redirect_uri = request.POST.get("redirect_uri")
+ if not code:
+ return JsonResponse({
+ "error": "invalid_grant",
+ }, status=400)
+
+ try:
+ grant = self.client.grants.get(code=code, expires__gt=now())
+ except CustomerSSOGrant.DoesNotExist:
+ # The server must return an invalid_grant error as the authorization code has already been used.
+ # The originally issued access token should be revoked (as per RFC6749-4.1.2)
+ CustomerSSOAccessToken.objects.filter(
+ client=self.client,
+ from_code=code
+ ).update(expires=now() - timedelta(seconds=1))
+ return JsonResponse({
+ "error": "invalid_grant",
+ "error_description": "Unknown or expired authorization code"
+ }, status=400)
+
+ if grant.redirect_uri != redirect_uri:
+ return JsonResponse({
+ "error": "invalid_grant",
+ "error_description": "Mismatch of redirect_uri"
+ }, status=400)
+
+ with transaction.atomic():
+ token = self.client.access_tokens.create(
+ customer=grant.customer,
+ token=get_random_string(128),
+ expires=now() + timedelta(hours=24),
+ scope=grant.scope,
+ from_code=code,
+ )
+ grant.delete()
+
+ return JsonResponse({
+ "access_token": token.token,
+ "token_type": "Bearer",
+ "expires_in": int((token.expires - now()).total_seconds()),
+ "id_token": generate_id_token(grant.customer, self.client, grant.auth_time, grant.nonce, grant.scope, token.expires)
+ }, headers={
+ 'Cache-Control': 'no-store',
+ 'Pragma': 'no-cache',
+ })
+
+
+class UserInfoView(View):
+ @method_decorator(csrf_exempt)
+ @method_decorator(never_cache)
+ @method_decorator(sensitive_post_parameters())
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
+
+ auth_header = request.headers.get('Authorization')
+ if auth_header:
+ method, token = auth_header.split(' ', 1)
+ if method != 'Bearer':
+ return JsonResponse({
+ "error": "invalid_request",
+ "error_description": "Unknown authorization method"
+ }, status=400, headers={
+ 'Access-Control-Allow-Origin': '*',
+ })
+ elif request.method == "POST" and "access_token" in request.POST:
+ token = request.POST.get("access_token")
+ else:
+ return HttpResponse(status=401, headers={
+ 'WWW-Authenticate': 'Bearer realm="example"',
+ 'Access-Control-Allow-Origin': '*',
+ })
+
+ try:
+ access_token = CustomerSSOAccessToken.objects.get(
+ token=token, expires__gt=now(), client__organizer=self.request.organizer,
+ )
+ except CustomerSSOAccessToken.DoesNotExist:
+ return JsonResponse({
+ "error": "invalid_token",
+ "error_description": "Unknown access token"
+ }, status=401, headers={
+ 'WWW-Authenticate': 'error="invalid_token"&error_description="Unknown access token"',
+ 'Access-Control-Allow-Origin': '*',
+ })
+ else:
+ self.customer = access_token.customer
+ self.access_token = access_token
+
+ r = super().dispatch(request, *args, **kwargs)
+ r['Access-Control-Allow-Origin'] = '*'
+ return r
+
+ def post(self, request, *args, **kwargs):
+ return self._handle(request)
+
+ def get(self, request, *args, **kwargs):
+ return self._handle(request)
+
+ def _handle(self, request):
+ return JsonResponse(customer_claims(self.customer, self.access_token.client.evaluated_scope(self.access_token.scope)))
+
+
+class KeysView(View):
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
+ r = super().dispatch(request, *args, **kwargs)
+ r['Access-Control-Allow-Origin'] = '*'
+ return r
+
+ def _encode_int(self, i):
+ hexi = hex(i)[2:]
+ return base64.urlsafe_b64encode(unhexlify((len(hexi) % 2) * '0' + hexi))
+
+ def get(self, request, *args, **kwargs):
+ privkey, pubkey = _get_or_create_server_keypair(request.organizer)
+ kid = hashlib.sha256(pubkey.encode()).hexdigest()[:16]
+ pubkey = RSA.import_key(pubkey)
+
+ return JsonResponse({
+ 'keys': [
+ {
+ 'kty': 'RSA',
+ 'alg': 'RS256',
+ 'kid': kid,
+ 'use': 'sig',
+ 'e': self._encode_int(pubkey.e).decode().rstrip("="),
+ 'n': self._encode_int(pubkey.n).decode().rstrip("="),
+ }
+ ]
+ })
+
+
+class ConfigurationView(View):
+ def dispatch(self, request, *args, **kwargs):
+ if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native:
+ raise Http404('Feature not enabled')
+ r = super().dispatch(request, *args, **kwargs)
+ r['Access-Control-Allow-Origin'] = '*'
+ return r
+
+ def get(self, request, *args, **kwargs):
+ return JsonResponse({
+ 'issuer': build_absolute_uri(request.organizer, 'presale:organizer.index').rstrip('/'),
+ 'authorization_endpoint': build_absolute_uri(
+ request.organizer, 'presale:organizer.oauth2.v1.authorize'
+ ),
+ 'token_endpoint': build_absolute_uri(
+ request.organizer, 'presale:organizer.oauth2.v1.token'
+ ),
+ 'userinfo_endpoint': build_absolute_uri(
+ request.organizer, 'presale:organizer.oauth2.v1.userinfo'
+ ),
+ 'jwks_uri': build_absolute_uri(
+ request.organizer, 'presale:organizer.oauth2.v1.jwks'
+ ),
+ 'scopes_supported': [k for k, v in CustomerSSOClient.SCOPE_CHOICES],
+ 'response_types_supported': RESPONSE_TYPES_SUPPORTED,
+ 'response_modes_supported': ['query', 'fragment'],
+ 'request_parameter_supported': False,
+ 'grant_types_supported': ['authorization_code', 'implicit'],
+ 'subject_types_supported': ['public'],
+ 'id_token_signing_alg_values_supported': ['RS256'],
+ 'token_endpoint_auth_methods_supported': [
+ 'client_secret_post', 'client_secret_basic'
+ ],
+ 'claims_supported': [
+ 'iss',
+ 'aud',
+ 'exp',
+ 'iat',
+ 'auth_time',
+ 'nonce',
+ 'c_hash',
+ 'at_hash',
+ 'sub',
+ 'locale',
+ 'name',
+ 'given_name',
+ 'family_name',
+ 'middle_name',
+ 'nickname',
+ 'email',
+ 'email_verified',
+ 'phone_number',
+ ],
+ 'request_uri_parameter_supported': False,
+
+ })
diff --git a/src/setup.py b/src/setup.py
index 8f95b91dc..537e9c46e 100644
--- a/src/setup.py
+++ b/src/setup.py
@@ -211,6 +211,7 @@ setup(
'psycopg2-binary',
'pycountry',
'pycparser==2.21',
+ 'pycryptodome==3.15.*',
'PyPDF2==2.9.*',
'python-bidi==0.4.*', # Support for Arabic in reportlab
'python-dateutil==2.8.*',
diff --git a/src/tests/presale/test_oidc_op.py b/src/tests/presale/test_oidc_op.py
new file mode 100644
index 000000000..c09760599
--- /dev/null
+++ b/src/tests/presale/test_oidc_op.py
@@ -0,0 +1,585 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import base64
+import json
+import re
+import time
+from binascii import hexlify
+from datetime import timedelta
+from urllib.parse import quote
+
+import jwt
+import pytest
+from bs4 import BeautifulSoup
+from Crypto.PublicKey import RSA
+from django.utils.timezone import now
+from django_scopes import scopes_disabled
+from freezegun import freeze_time
+from tests.base import extract_form_fields
+
+from pretix.base.customersso.oidc import _hash_scheme
+from pretix.base.models import Event, Organizer
+from pretix.base.models.customers import (
+ CustomerSSOAccessToken, CustomerSSOClient,
+)
+
+
+@pytest.fixture
+def env():
+ o = Organizer.objects.create(name='Big Events LLC', slug='bigevents')
+ o.settings.customer_accounts = True
+ o.settings.customer_accounts_native = True
+ event = Event.objects.create(
+ organizer=o, name='Conference', slug='conf',
+ date_from=now() + timedelta(days=10),
+ live=True, is_public=False
+ )
+ customer = o.customers.create(email='john@example.org', is_verified=True, identifier="ABC123",
+ name_parts={'_scheme': 'given_family', 'given_name': 'John', 'family_name': 'Doe'},
+ phone='+49302270')
+ customer.set_password('foo')
+ customer.save()
+ return o, event
+
+
+@pytest.fixture
+def ssoclient(env, client):
+ c = CustomerSSOClient(organizer=env[0], name="Test",
+ redirect_uris="https://example.net https://example.org/path?query=value#hash=foo")
+ secret = c.set_client_secret()
+ c.save()
+ return c, secret
+
+
+@pytest.mark.django_db
+def test_authorize_final_errors(env, client, ssoclient):
+ r = client.get('/bigevents/oauth2/v1/authorize')
+ assert r.status_code == 400
+ assert b'client_id missing' in r.content
+
+ r = client.get('/bigevents/oauth2/v1/authorize?client_id=abc')
+ assert r.status_code == 400
+ assert b'invalid client_id' in r.content
+
+ r = client.get(f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&response_type=code')
+ assert r.status_code == 400
+ assert b'invalid redirect_uri' in r.content
+
+ r = client.get(f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&redirect_uri=https://google.com')
+ assert r.status_code == 400
+ assert b'invalid redirect_uri' in r.content
+
+ r = client.get(
+ f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&'
+ f'redirect_uri={quote("https://example.org/path?query=value#hash=foo")}&scope=openid+profile'
+ )
+ assert r.status_code == 400
+ assert b"response_type unsupported" in r.content
+
+ r = client.get(
+ f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&response_type=id_token&response_mode=query&'
+ f'redirect_uri={quote("https://example.org/path?query=value#hash=foo")}&scope=openid+profile'
+ )
+ assert r.status_code == 400
+ assert b"response_mode query" in r.content
+
+ r = client.get(
+ f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&response_type=id_token&response_mode=bogus'
+ f'&redirect_uri=https://example.net')
+ assert r.status_code == 400
+ assert b'invalid response_mode' in r.content
+
+
+@pytest.mark.django_db
+def test_authorize_basic_redirect_errors(env, client, ssoclient):
+ r = client.get(
+ f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&redirect_uri=https://example.net&response_type=code')
+ assert r.status_code == 302
+ assert r.headers['Location'] == 'https://example.net?error=invalid_scope&' \
+ 'error_description=scope+%27openid%27+must+be+requested'
+
+ r = client.get(
+ f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&redirect_uri=https://example.net&response_type=id_token'
+ f'&scope=openid+email')
+ assert r.status_code == 302
+ assert r.headers['Location'] == 'https://example.net#error=invalid_request&' \
+ 'error_description=nonce+is+required+in+implicit+or+hybrid+flow'
+
+
+@pytest.mark.django_db
+def test_authorize_redirect_post_to_get(env, client, ssoclient):
+ r = client.post('/bigevents/oauth2/v1/authorize', {
+ 'client_id': ssoclient[0].client_id,
+ 'redirect_uri': 'https://example.net',
+ 'response_type': 'code',
+ 'scope': 'openid+profile',
+ })
+ assert r.status_code == 302
+ assert r.headers['Location'] == f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https%3A%2F%2Fexample.net&response_type=code&scope=openid%2Bprofile'
+
+
+@pytest.mark.django_db
+def test_authorize_success_with_login(env, client, ssoclient):
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&' \
+ f'state=STATE&' \
+ f'scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 200
+ assert b'login-email' in r.content
+
+ doc = BeautifulSoup(r.content, "lxml")
+ d = extract_form_fields(doc)
+ d.update({
+ 'login-email': 'john@example.org',
+ 'login-password': 'foo',
+ })
+
+ r = client.post(url, d)
+ assert r.status_code == 302
+ assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location'])
+
+
+@pytest.mark.django_db
+def test_authorize_success_with_existing_session(env, client, ssoclient):
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 302
+
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&' \
+ f'state=STATE&' \
+ f'scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location'])
+
+
+@pytest.mark.django_db
+def test_authorize_with_prompt_none(env, client, ssoclient):
+ url = f'/bigevents/oauth2/v1/authorize?' \
+ f'prompt=none&' \
+ f'client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&state=STATE&scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ assert r.headers['Location'] == 'https://example.net?' \
+ 'error=interaction_required&' \
+ 'error_description=user+is+not+logged+in+but+no+prompt+is+allowed&' \
+ 'state=STATE'
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 302
+
+ url = f'/bigevents/oauth2/v1/authorize?' \
+ f'prompt=none&' \
+ f'client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&state=STATE&scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ assert re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location'])
+
+
+@pytest.mark.django_db
+def test_authorize_require_login_if_prompt_requires_it_or_is_expired(env, client, ssoclient):
+ with freeze_time("2021-04-10T11:00:00+02:00"):
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 302
+
+ url = f'/bigevents/oauth2/v1/authorize?' \
+ f'prompt=login&' \
+ f'client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&state=STATE&scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 200
+ assert b'login-email' in r.content
+
+ with freeze_time("2021-04-10T11:59:00+02:00"):
+ url = f'/bigevents/oauth2/v1/authorize?' \
+ f'max_age=3600&' \
+ f'client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&state=STATE&scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+
+ with freeze_time("2021-04-10T12:01:00+02:00"):
+ url = f'/bigevents/oauth2/v1/authorize?' \
+ f'max_age=3600&' \
+ f'client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&state=STATE&scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 200
+ assert b'login-email' in r.content
+
+
+@pytest.mark.django_db
+def test_token_require_client_id(env, client, ssoclient):
+ r = client.post('/bigevents/oauth2/v1/token', {
+ })
+ assert r.status_code == 400
+ assert b'invalid_client' in r.content
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode('wrong:wrong'.encode()).decode())
+ assert r.status_code == 401
+ assert b'invalid_client' in r.content
+ assert 'invalid_client' in r.headers['WWW-Authenticate']
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:wrong'.encode()).decode())
+ assert r.status_code == 401
+ assert b'invalid_client' in r.content
+ assert 'invalid_client' in r.headers['WWW-Authenticate']
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 400
+ assert b'unsupported_grant_type' in r.content
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'client_id': ssoclient[0].client_id
+ })
+ assert r.status_code == 400
+ assert b'confidential' in r.content
+
+ ssoclient[0].client_type = CustomerSSOClient.CLIENT_PUBLIC
+ ssoclient[0].save()
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'client_id': ssoclient[0].client_id
+ })
+ assert r.status_code == 400
+ assert b'unsupported_grant_type' in r.content
+
+
+def _authorization_step(client, ssoclient):
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 302
+
+ url = f'/bigevents/oauth2/v1/authorize?' \
+ f'prompt=none&' \
+ f'nonce=NONCE123&' \
+ f'client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code&state=STATE&scope=openid+profile+email+phone'
+ r = client.get(url)
+ assert r.status_code == 302
+ m = re.match(r'https://example.net\?code=([a-z0-9A-Z]{64})&state=STATE', r.headers['Location'])
+ assert m
+ return m.group(1)
+
+
+@pytest.mark.django_db
+def test_token_missing_or_mismatching_parameters(env, client, ssoclient):
+ code = _authorization_step(client, ssoclient)
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'test'
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 400
+ assert b'unsupported_grant_type' in r.content
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'authorization_code',
+ 'code': 'fail',
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 400
+ assert b'Unknown or expired authorization code' in r.content
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'redirect_uri': 'https://google.com'
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 400
+ assert b'Mismatch of redirect_uri' in r.content
+
+
+@pytest.mark.django_db
+def test_token_success(env, client, ssoclient):
+ code = _authorization_step(client, ssoclient)
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'redirect_uri': 'https://example.net'
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 200
+ d = json.loads(r.content)
+ assert d['access_token']
+ assert d['token_type'].lower() == 'bearer'
+ assert 86390 < d['expires_in'] <= 86400
+ token = d['id_token']
+
+ env[0].settings.flush()
+ decoded = jwt.decode(token, env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"],
+ audience=ssoclient[0].client_id)
+ assert decoded['iss'] == 'http://example.com/bigevents'
+ assert decoded['aud'] == ssoclient[0].client_id
+ assert decoded['sub'] == "ABC123"
+ assert time.time() + 86390 < decoded['exp'] <= time.time() + 86400
+ assert time.time() - 10 < decoded['iat'] <= time.time()
+ assert time.time() - 10 < decoded['auth_time'] <= time.time()
+ assert 'email' not in decoded
+ assert decoded['nonce'] == 'NONCE123'
+
+ # Assert that code can only be used once
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'code',
+ 'code': code,
+ 'redirect_uri': 'https://example.net'
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 400
+
+ # Assert that auth token is revoked after reuse
+ with scopes_disabled():
+ CustomerSSOAccessToken.objects.get(token=d['access_token']).expires < now()
+
+
+@pytest.mark.django_db
+def test_scope_enforcement(env, client, ssoclient):
+ ssoclient[0].allowed_scopes = ['openid', 'profile']
+ ssoclient[0].save()
+ code = _authorization_step(client, ssoclient)
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'redirect_uri': 'https://example.net'
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 200
+ d = json.loads(r.content)
+ token = d['id_token']
+ env[0].settings.flush()
+ decoded = jwt.decode(token, env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"],
+ audience=ssoclient[0].client_id)
+ assert 'email' not in decoded
+ assert decoded['nonce'] == 'NONCE123'
+
+
+@pytest.mark.django_db
+def test_token_client_secret_post(env, client, ssoclient):
+ code = _authorization_step(client, ssoclient)
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'client_id': ssoclient[0].client_id,
+ 'client_secret': ssoclient[1],
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'redirect_uri': 'https://example.net'
+ })
+ assert r.status_code == 200
+ d = json.loads(r.content)
+ assert d['access_token']
+ assert d['token_type'].lower() == 'bearer'
+ assert 86390 < d['expires_in'] <= 86400
+
+
+@pytest.mark.django_db
+def test_authorize_implicit_flow(env, client, ssoclient):
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 302
+
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=id_token&' \
+ f'state=STATE&' \
+ f'nonce=NONCE123&' \
+ f'scope=openid+profile+email'
+ r = client.get(url)
+ assert r.status_code == 302
+ match = re.match(r'https://example.net#id_token=([^&]+)&state=STATE', r.headers['Location'])
+ assert match
+
+ env[0].settings.flush()
+ decoded = jwt.decode(match.group(1), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"],
+ audience=ssoclient[0].client_id)
+ assert decoded['iss'] == 'http://example.com/bigevents'
+ assert decoded['aud'] == ssoclient[0].client_id
+ assert decoded['sub'] == "ABC123"
+ assert time.time() + 86390 < decoded['exp'] <= time.time() + 86400
+ assert time.time() - 10 < decoded['iat'] <= time.time()
+ assert time.time() - 10 < decoded['auth_time'] <= time.time()
+ assert 'email' in decoded
+ assert decoded['nonce'] == 'NONCE123'
+
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=id_token+token&' \
+ f'state=STATE&' \
+ f'nonce=NONCE123&' \
+ f'scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ match = re.match(r'https://example.net#access_token=([^&]+)&token_type=Bearer&'
+ r'expires_in=([^&]+)&id_token=([^&]+)&state=STATE', r.headers['Location'])
+ assert match
+ assert 86390 < int(match.group(2)) <= 86400
+ decoded = jwt.decode(match.group(3), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"],
+ audience=ssoclient[0].client_id)
+ assert decoded['at_hash'] == _hash_scheme(match.group(1))
+
+
+@pytest.mark.django_db
+def test_authorize_hybrid_flow(env, client, ssoclient):
+ r = client.post('/bigevents/account/login', {
+ 'email': 'john@example.org',
+ 'password': 'foo',
+ })
+ assert r.status_code == 302
+
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code+id_token&' \
+ f'state=STATE&' \
+ f'nonce=NONCE123&' \
+ f'scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ match = re.match(r'https://example.net#code=([^&]+)&id_token=([^&]+)&state=STATE', r.headers['Location'])
+ assert match
+ env[0].settings.flush()
+ decoded = jwt.decode(match.group(2), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"],
+ audience=ssoclient[0].client_id)
+ assert decoded['c_hash'] == _hash_scheme(match.group(1))
+
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code+id_token+token&' \
+ f'state=STATE&' \
+ f'nonce=NONCE123&' \
+ f'scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ match = re.match(r'https://example.net#code=([^&]+)&access_token=([^&]+)&token_type=Bearer&'
+ r'expires_in=([^&]+)&id_token=([^&]+)&state=STATE', r.headers['Location'])
+ assert match
+ decoded = jwt.decode(match.group(4), env[0].settings.sso_server_signing_key_rsa256_public, algorithms=["RS256"],
+ audience=ssoclient[0].client_id)
+ assert decoded['c_hash'] == _hash_scheme(match.group(1))
+ assert decoded['at_hash'] == _hash_scheme(match.group(2))
+
+ url = f'/bigevents/oauth2/v1/authorize?client_id={ssoclient[0].client_id}&' \
+ f'redirect_uri=https://example.net&' \
+ f'response_type=code+token&' \
+ f'state=STATE&' \
+ f'nonce=NONCE123&' \
+ f'scope=openid+profile'
+ r = client.get(url)
+ assert r.status_code == 302
+ match = re.match(r'https://example.net#code=([^&]+)&access_token=([^&]+)&token_type=Bearer&'
+ r'expires_in=([^&]+)&state=STATE', r.headers['Location'])
+ assert match
+
+
+def _acquire_token(client, ssoclient):
+ code = _authorization_step(client, ssoclient)
+
+ r = client.post('/bigevents/oauth2/v1/token', {
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'redirect_uri': 'https://example.net'
+ }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(f'{ssoclient[0].client_id}:{ssoclient[1]}'.encode()).decode())
+ assert r.status_code == 200
+ d = json.loads(r.content)
+ return d['access_token']
+
+
+@pytest.mark.django_db
+def test_userinfo_auth_and_claims(env, client, ssoclient):
+ ssoclient[0].allowed_scopes = ['openid', 'profile', 'phone']
+ ssoclient[0].save()
+
+ r = client.get('/bigevents/oauth2/v1/userinfo')
+ assert r.status_code == 401
+
+ r = client.get('/bigevents/oauth2/v1/userinfo', HTTP_AUTHORIZATION='Basic foo')
+ assert r.status_code == 400
+
+ r = client.get('/bigevents/oauth2/v1/userinfo', HTTP_AUTHORIZATION='Bearer invalid')
+ assert r.status_code == 401
+
+ token = _acquire_token(client, ssoclient)
+
+ r = client.get('/bigevents/oauth2/v1/userinfo', HTTP_AUTHORIZATION=f'Bearer {token}')
+ assert r.status_code == 200
+
+ r = client.post('/bigevents/oauth2/v1/userinfo', {'access_token': token})
+ assert r.status_code == 200
+
+ data = json.loads(r.content)
+ assert data == {
+ 'sub': 'ABC123',
+ 'locale': 'en',
+ 'name': 'John Doe',
+ 'given_name': 'John',
+ 'family_name': 'Doe',
+ 'phone_number': '+49 30 2270'
+ }
+
+
+@pytest.mark.django_db
+def test_config_endpoint(env, client, ssoclient):
+ r = client.get('/bigevents/.well-known/openid-configuration')
+ assert r.status_code == 200
+ data = json.loads(r.content)
+ assert data['issuer'] == 'http://example.com/bigevents'
+
+
+@pytest.mark.django_db
+def test_keys_endpoint(env, client, ssoclient):
+ r = client.get('/bigevents/oauth2/v1/keys')
+ assert r.status_code == 200
+ data = json.loads(r.content)
+ env[0].settings.flush()
+
+ def decode_int(d: str):
+ b = d.encode()
+ padded = b + (b'=' * (-len(b) % 4))
+ return int(hexlify(base64.urlsafe_b64decode(padded)), 16)
+
+ e = decode_int(data['keys'][0]['e'])
+ n = decode_int(data['keys'][0]['n'])
+ pubkey = RSA.construct((n, e))
+ representation = pubkey.export_key('PEM').decode()
+ assert representation.strip() == env[0].settings.sso_server_signing_key_rsa256_public.strip()