from urllib.parse import urlsplit from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone class BackchannelClient(models.Model): """An administrator-approved OIDC client that can receive logout tokens.""" application = models.OneToOneField( settings.OAUTH2_PROVIDER_APPLICATION_MODEL, on_delete=models.CASCADE ) logout_uri = models.URLField(max_length=2048) enabled = models.BooleanField(default=True) def clean(self): super().clean() url = urlsplit(self.logout_uri) if ( url.scheme != "https" or not url.hostname or url.username or url.password or url.fragment ): raise ValidationError( { "logout_uri": "Use an absolute HTTPS URL without credentials or fragment." } ) if self.application_id and self.application.algorithm != "RS256": raise ValidationError( { "application": "Back-channel logout currently supports RS256 OIDC clients." } ) def __str__(self): return self.application.name class OIDCClientIdentity(models.Model): """Remember a subject only after it has authenticated to this OIDC client.""" application = models.ForeignKey( settings.OAUTH2_PROVIDER_APPLICATION_MODEL, on_delete=models.CASCADE ) user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL ) subject = models.CharField(max_length=255) last_login = models.DateTimeField(default=timezone.now) logout_queued = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint( fields=["application", "subject"], name="oidc_unique_client_subject" ) ] def __str__(self): return f"{self.application.name} / {self.subject}" class BackchannelDelivery(models.Model): """Durable per-client logout queue; it never stores tokens or key material.""" identity = models.ForeignKey(OIDCClientIdentity, on_delete=models.CASCADE) client = models.ForeignKey(BackchannelClient, on_delete=models.CASCADE) state = models.CharField(max_length=12, default="pending") created_at = models.DateTimeField(default=timezone.now) next_attempt_at = models.DateTimeField(default=timezone.now, db_index=True) locked_until = models.DateTimeField(null=True, blank=True) lock_token = models.CharField(max_length=36, blank=True, default="") attempts = models.PositiveIntegerField(default=0) last_error = models.CharField(max_length=80, blank=True, default="") completed_at = models.DateTimeField(null=True, blank=True) class Meta: indexes = [ models.Index( fields=["state", "next_attempt_at"], name="oidc_logout_due_idx" ) ]