"""OIDC back-channel logout on account deactivation, independent of any RP.""" import logging import uuid from datetime import timedelta from urllib.parse import urlsplit import requests from django.conf import settings from django.db import transaction from django.db.models import Q from django.utils import timezone from jwcrypto import jwt from oauth2_provider.settings import oauth2_settings from oauth2_provider.utils import jwk_from_pem from .models import BackchannelDelivery, OIDCClientIdentity from .validators import _is_active_employee logger = logging.getLogger(__name__) LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout" def remember_login(request): if not settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: return user = getattr(request, "user", None) client = getattr(request, "client", None) scopes = getattr(request, "scopes", None) or [] if not user or not user.is_authenticated or not client or "openid" not in scopes: return OIDCClientIdentity.objects.update_or_create( application_id=client.pk, subject=str(user.pk), defaults={"user": user, "last_login": timezone.now(), "logout_queued": False}, ) def enqueue_inactive_identities(): """Reconcile remembered logins, including missed signals and future dates.""" if not settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: return 0 ids = list( OIDCClientIdentity.objects.filter( logout_queued=False, application__backchannelclient__enabled=True ).values_list("pk", flat=True) ) count = 0 for identity_id in ids: with transaction.atomic(): identity = ( OIDCClientIdentity.objects.select_for_update(of=("self",)) .select_related("user", "application__backchannelclient") .get(pk=identity_id) ) client = identity.application.backchannelclient if ( identity.logout_queued or not client.enabled or _is_active_employee(identity.user) ): continue BackchannelDelivery.objects.create(identity=identity, client=client) identity.logout_queued = True identity.save(update_fields=["logout_queued"]) count += 1 return count def make_logout_token(identity, now): key = jwk_from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY) token = jwt.JWT( header={"alg": "RS256", "kid": key.thumbprint(), "typ": "logout+jwt"}, claims={ "iss": settings.OIDC_ISSUER_URL, "sub": identity.subject, "aud": identity.application.client_id, "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=5)).timestamp()), "jti": str(uuid.uuid4()), "events": {LOGOUT_EVENT: {}}, }, ) token.make_signed_token(key) return token.serialize() def dispatch_due_deliveries(limit=100): if not settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: return 0 now = timezone.now() due = list( BackchannelDelivery.objects.filter(state="pending", next_attempt_at__lte=now) .filter(Q(locked_until__isnull=True) | Q(locked_until__lte=now)) .order_by("next_attempt_at", "pk") .values_list("pk", flat=True)[:limit] ) processed = 0 for pk in due: claim_now = timezone.now() token = str(uuid.uuid4()) claimed = ( BackchannelDelivery.objects.filter( pk=pk, state="pending", next_attempt_at__lte=claim_now ) .filter(Q(locked_until__isnull=True) | Q(locked_until__lte=claim_now)) .update(locked_until=claim_now + timedelta(seconds=60), lock_token=token) ) if not claimed: continue delivery = BackchannelDelivery.objects.select_related( "client__application", "identity__user", "identity__application" ).get(pk=pk) identity, client = delivery.identity, delivery.client if identity.application_id != client.application_id: _finish(delivery, token, "failed", "client_changed") processed += 1 continue if not client.enabled: _retry(delivery, token, "client_disabled") processed += 1 continue if ( _is_active_employee(identity.user) or identity.last_login > delivery.created_at ): _cancel_stale(delivery, token) processed += 1 continue url = urlsplit(client.logout_uri) if ( url.scheme != "https" or not url.hostname or url.username or url.password or url.fragment or client.application.algorithm != "RS256" ): _finish(delivery, token, "failed", "invalid_client_configuration") processed += 1 continue try: signed = make_logout_token(identity, timezone.now()) response = requests.post( client.logout_uri, data={"logout_token": signed}, timeout=(3, 5), allow_redirects=False, ) status = response.status_code except (requests.RequestException, ValueError) as error: _retry(delivery, token, type(error).__name__) else: if status == 200: _finish(delivery, token, "delivered", "") elif status in (408, 429) or status >= 500: _retry(delivery, token, f"http_{status}") else: _finish(delivery, token, "failed", f"http_{status}") processed += 1 return processed def _finish(delivery, token, state, error): BackchannelDelivery.objects.filter( pk=delivery.pk, lock_token=token, state="pending" ).update( state=state, attempts=delivery.attempts + 1, last_error=error, completed_at=timezone.now(), locked_until=None, lock_token="", ) def _cancel_stale(delivery, token): # A re-hire or a later login invalidates this old subject-wide message. # Permit a later dismissal to produce a fresh notification. with transaction.atomic(): identity = OIDCClientIdentity.objects.select_for_update().get( pk=delivery.identity_id ) changed = BackchannelDelivery.objects.filter( pk=delivery.pk, lock_token=token, state="pending" ).update( state="cancelled", attempts=delivery.attempts + 1, completed_at=timezone.now(), locked_until=None, lock_token="", ) if ( changed and not BackchannelDelivery.objects.filter( identity=identity, state="pending" ).exists() ): identity.logout_queued = False identity.save(update_fields=["logout_queued"]) def _retry(delivery, token, error): attempts = delivery.attempts + 1 delay = min(3600, 5 * 2 ** min(attempts, 10)) BackchannelDelivery.objects.filter( pk=delivery.pk, lock_token=token, state="pending" ).update( attempts=attempts, last_error=error, next_attempt_at=timezone.now() + timedelta(seconds=delay), locked_until=None, lock_token="", ) logger.warning( "OIDC back-channel logout delivery will be retried", extra={"delivery_id": delivery.pk, "reason": error}, )