diff --git a/dnscms/dnscms/migrations/0001_initial.py b/dnscms/dnscms/migrations/0001_initial.py
new file mode 100644
index 0000000..d397d15
--- /dev/null
+++ b/dnscms/dnscms/migrations/0001_initial.py
@@ -0,0 +1,25 @@
+# Generated by Django 6.0.7 on 2026-08-04 17:43
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='GaltinnUser',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('galtinn_id', models.CharField(max_length=64, unique=True)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='galtinn_user', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/dnscms/dnscms/migrations/__init__.py b/dnscms/dnscms/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dnscms/dnscms/models.py b/dnscms/dnscms/models.py
new file mode 100644
index 0000000..b5b371d
--- /dev/null
+++ b/dnscms/dnscms/models.py
@@ -0,0 +1,12 @@
+from django.contrib.auth.models import User
+from django.db import models
+
+
+class GaltinnUser(models.Model):
+ """Links a local user to its Galtinn identity"""
+
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="galtinn_user")
+ galtinn_id = models.CharField(max_length=64, unique=True)
+
+ def __str__(self):
+ return f"{self.user.username} (galtinn #{self.galtinn_id})"
diff --git a/dnscms/dnscms/oidc.py b/dnscms/dnscms/oidc.py
new file mode 100644
index 0000000..07c27da
--- /dev/null
+++ b/dnscms/dnscms/oidc.py
@@ -0,0 +1,206 @@
+"""
+OIDC login via Galtinn.
+
+Members of GALTINN_EDITOR_GROUP get editor access.
+"""
+
+import logging
+
+import requests
+from authlib.integrations.django_client import OAuth, OAuthError
+from django.conf import settings
+from django.contrib import admin, messages
+from django.contrib.auth import login
+from django.contrib.auth.models import Group, User
+from django.db import transaction
+from django.db.models import Q
+from django.http import Http404
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils.http import url_has_allowed_host_and_scheme
+from wagtail.admin.forms.account import NameEmailForm
+from joserfc.errors import JoseError
+from wagtail.admin.views.account import AccountView, NameEmailSettingsPanel
+
+from dnscms.models import GaltinnUser
+
+logger = logging.getLogger(__name__)
+
+WAGTAIL_EDITORS_GROUP = "Editors" # stock Wagtail group, has admin access via wagtailcore
+REQUIRED_CLAIMS = ("sub", "preferred_username", "email")
+
+oauth = OAuth()
+oauth.register(
+ "galtinn",
+ client_id=settings.OIDC_RP_CLIENT_ID,
+ client_secret=settings.OIDC_RP_CLIENT_SECRET,
+ server_metadata_url=f"{settings.GALTINN_BASE_URL}/api/oauth/.well-known/openid-configuration",
+ client_kwargs={"scope": "openid profile email", "code_challenge_method": "S256"},
+)
+
+
+class LoginRefused(Exception):
+ pass
+
+
+def is_enabled():
+ return bool(settings.OIDC_RP_CLIENT_ID and settings.GALTINN_EDITOR_GROUP)
+
+
+def context(request):
+ return {"oidc_enabled": is_enabled()}
+
+
+def _is_editor(claims):
+ group = settings.GALTINN_EDITOR_GROUP
+ return bool(group) and group in claims.get("groups", [])
+
+
+def _create_user(claims):
+ username, email = claims["preferred_username"], claims["email"]
+ # an existing local user must be linked by hand, never matched on username or email
+ if User.objects.filter(Q(username__iexact=username) | Q(email__iexact=email)).exists():
+ logger.error(
+ "Galtinn user #%s (%r, %r) clashes with an unlinked local user; "
+ "refusing login, link manually",
+ claims["sub"],
+ username,
+ email,
+ )
+ raise LoginRefused("clash with unlinked local user")
+ editors = Group.objects.get(name=WAGTAIL_EDITORS_GROUP)
+ with transaction.atomic():
+ user = User.objects.create_user(
+ username=username,
+ email=email,
+ first_name=claims.get("given_name", ""),
+ last_name=claims.get("family_name", ""),
+ )
+ user.groups.add(editors)
+ GaltinnUser.objects.create(user=user, galtinn_id=claims["sub"])
+ logger.info("Created user %r from Galtinn login", username)
+ return user
+
+
+def _sync_user(user, claims):
+ username = claims["preferred_username"]
+ if user.username != username:
+ if User.objects.exclude(pk=user.pk).filter(username__iexact=username).exists():
+ logger.error(
+ "Galtinn username %r maps to user #%s but another local user owns it; "
+ "refusing login, resolve manually",
+ username,
+ user.pk,
+ )
+ raise LoginRefused("username collision")
+ user.username = username
+ user.email = claims["email"]
+ user.first_name = claims.get("given_name", user.first_name)
+ user.last_name = claims.get("family_name", user.last_name)
+ user.is_active = True
+ # linked users log in via Galtinn only, so access follows the Galtinn group
+ if user.has_usable_password():
+ user.set_unusable_password()
+ user.save()
+ user.groups.add(Group.objects.get(name=WAGTAIL_EDITORS_GROUP))
+ return user
+
+
+def _revoke(user):
+ user.groups.remove(*Group.objects.filter(name=WAGTAIL_EDITORS_GROUP))
+ if user.is_superuser:
+ logger.warning("Superuser %r is not in the Galtinn editor group", user.username)
+ return
+ user.is_active = False
+ user.save(update_fields=["is_active"])
+ logger.info("Deactivated %r: not in Galtinn editor group", user.username)
+
+
+def resolve_user(claims):
+ """Return the local user for these claims, or raise LoginRefused."""
+ missing = [name for name in REQUIRED_CLAIMS if not claims.get(name)]
+ if missing:
+ raise LoginRefused(f"missing claims: {', '.join(missing)}")
+ claims = {**claims, "sub": str(claims["sub"])}
+
+ # only the stored Galtinn ID identifies a user
+ user = User.objects.filter(galtinn_user__galtinn_id=claims["sub"]).first()
+ if user is None:
+ if not _is_editor(claims):
+ raise LoginRefused(f"unknown user #{claims['sub']} not in editor group")
+ return _create_user(claims)
+ if not _is_editor(claims):
+ _revoke(user)
+ raise LoginRefused("not in editor group")
+ return _sync_user(user, claims)
+
+
+@admin.register(GaltinnUser)
+class GaltinnUserAdmin(admin.ModelAdmin):
+ list_display = ["user", "galtinn_id"]
+ search_fields = ["user__username", "user__email", "galtinn_id"]
+ autocomplete_fields = ["user"]
+
+
+class GaltinnNameEmailForm(NameEmailForm):
+ """Read-only: name and email are overwritten from Galtinn claims on every login"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field in self.fields.values():
+ field.disabled = True
+
+
+class GaltinnNameEmailPanel(NameEmailSettingsPanel):
+ template_name = "wagtailadmin/account/settings_panels/name_email_galtinn.html"
+ form_class = GaltinnNameEmailForm
+
+
+class GaltinnAccountView(AccountView):
+ """Account view where Galtinn-linked users get a read-only name/email panel"""
+
+ def get_panels(self):
+ panels = super().get_panels()
+ if not GaltinnUser.objects.filter(user=self.request.user).exists():
+ return panels
+ return [
+ GaltinnNameEmailPanel(self.request, panel.user, panel.profile)
+ if panel.name == "name_email"
+ else panel
+ for panel in panels
+ ]
+
+
+def oidc_login(request):
+ if not is_enabled():
+ raise Http404
+ next_url = request.GET.get("next")
+ if next_url:
+ request.session["oidc_next"] = next_url
+ # from WAGTAIL_BASE_URL, not the request: the proxy strips https and the URI must match exactly
+ redirect_uri = settings.WAGTAIL_BASE_URL + reverse("oidc_callback")
+ return oauth.galtinn.authorize_redirect(request, redirect_uri)
+
+
+def oidc_callback(request):
+ if not is_enabled():
+ raise Http404
+ try:
+ token = oauth.galtinn.authorize_access_token(request)
+ except (OAuthError, JoseError, requests.RequestException) as exc:
+ logger.warning("Galtinn OAuth flow failed: %s", exc)
+ messages.error(request, "Innlogging via Galtinn feilet. Prøv igjen.")
+ return redirect("wagtailadmin_login")
+ try:
+ user = resolve_user(token.get("userinfo") or {})
+ except LoginRefused as exc:
+ logger.warning("Galtinn login refused: %s", exc)
+ messages.error(
+ request, "Du har ikke tilgang til publiseringssystemet. Ta kontakt med edb@neuf.no."
+ )
+ return redirect("wagtailadmin_login")
+ login(request, user)
+ next_url = request.session.pop("oidc_next", "")
+ if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
+ return redirect(next_url)
+ return redirect("wagtailadmin_home")
diff --git a/dnscms/dnscms/settings/base.py b/dnscms/dnscms/settings/base.py
index 7c9074c..7c06d79 100644
--- a/dnscms/dnscms/settings/base.py
+++ b/dnscms/dnscms/settings/base.py
@@ -92,6 +92,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
+ "dnscms.oidc.context",
],
},
},
@@ -204,6 +205,13 @@ FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000")
# Unset means no webhooks are sent.
REVALIDATE_WEBHOOK_SECRET = os.environ.get("REVALIDATE_WEBHOOK_SECRET", "")
+# OIDC login via Galtinn (see dnscms/oidc.py)
+GALTINN_BASE_URL = os.environ.get("GALTINN_BASE_URL", "https://galtinn.neuf.no").rstrip("/")
+# Galtinn group whose members get editor access. OIDC login needs this and the client ID.
+GALTINN_EDITOR_GROUP = os.environ.get("GALTINN_EDITOR_GROUP", "")
+OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "")
+OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "")
+
WAGTAIL_HEADLESS_PREVIEW = {
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/api/preview"},
"SERVE_BASE_URL": FRONTEND_BASE_URL,
diff --git a/dnscms/dnscms/static/graphics/guard-pig.svg b/dnscms/dnscms/static/graphics/guard-pig.svg
new file mode 100644
index 0000000..00bcb43
--- /dev/null
+++ b/dnscms/dnscms/static/graphics/guard-pig.svg
@@ -0,0 +1,78 @@
+
diff --git a/dnscms/dnscms/templates/wagtailadmin/account/settings_panels/name_email_galtinn.html b/dnscms/dnscms/templates/wagtailadmin/account/settings_panels/name_email_galtinn.html
new file mode 100644
index 0000000..6cb571c
--- /dev/null
+++ b/dnscms/dnscms/templates/wagtailadmin/account/settings_panels/name_email_galtinn.html
@@ -0,0 +1,3 @@
+{% load wagtailadmin_tags %}
+{% help_block status="info" %}Navn og e-post må endres i Galtinn.{% endhelp_block %}
+{% include "wagtailadmin/account/settings_panels/base.html" %}
diff --git a/dnscms/dnscms/templates/wagtailadmin/login.html b/dnscms/dnscms/templates/wagtailadmin/login.html
new file mode 100644
index 0000000..3b6b032
--- /dev/null
+++ b/dnscms/dnscms/templates/wagtailadmin/login.html
@@ -0,0 +1,103 @@
+{% extends "wagtailadmin/admin_base.html" %}
+{% load i18n static wagtailadmin_tags %}
+{% block titletag %}{% trans "Sign in" %}{% endblock %}
+{% block bodyclass %}login{% endblock %}
+
+{% block furniture %}
+
+
+ cms.neuf.no
+
+
+ {# Always show messages div so it can be appended to by JS #}
+ {% if messages or form.errors %}
+
+ {% for error in form.non_field_errors %}
+ - {{ error }}
+ {% endfor %}
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% if oidc_enabled %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/dnscms/dnscms/urls.py b/dnscms/dnscms/urls.py
index 85c3cc6..e1d0604 100644
--- a/dnscms/dnscms/urls.py
+++ b/dnscms/dnscms/urls.py
@@ -4,13 +4,24 @@ from django.urls import include, path
from grapple import urls as grapple_urls
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
+from wagtail.admin.auth import require_admin_access
from wagtail.documents import urls as wagtaildocs_urls
+from dnscms import oidc
+
urlpatterns = [
path("api/", include(grapple_urls)),
path("django-admin/", admin.site.urls),
+ # overrides Wagtail's account view, same auth guard as the wagtailadmin urls
+ path(
+ "admin/account/",
+ require_admin_access(oidc.GaltinnAccountView.as_view()),
+ name="wagtailadmin_account",
+ ),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
+ path("oidc/login/", oidc.oidc_login, name="oidc_login"),
+ path("oidc/callback/", oidc.oidc_callback, name="oidc_callback"),
]
diff --git a/dnscms/pyproject.toml b/dnscms/pyproject.toml
index ce87d88..a096973 100644
--- a/dnscms/pyproject.toml
+++ b/dnscms/pyproject.toml
@@ -13,6 +13,8 @@ dependencies = [
"psycopg2-binary>=2.9.12,<3",
"gunicorn>=26.2.0,<27",
"whitenoise>=6.12.0,<7",
+ "authlib>=1.6,<2",
+ "requests>=2.34,<3",
]
[dependency-groups]
diff --git a/dnscms/tests/test_oidc.py b/dnscms/tests/test_oidc.py
new file mode 100644
index 0000000..bc0a0c9
--- /dev/null
+++ b/dnscms/tests/test_oidc.py
@@ -0,0 +1,377 @@
+import logging
+import re
+
+import pytest
+import requests
+from authlib.integrations.django_client import OAuthError
+from django.contrib.auth.models import Group, Permission, User
+from django.http import HttpResponseRedirect
+from django.urls import reverse
+from joserfc.errors import JoseError
+
+from dnscms import oidc
+from dnscms.models import GaltinnUser
+from dnscms.oidc import WAGTAIL_EDITORS_GROUP, LoginRefused, resolve_user
+
+pytestmark = pytest.mark.django_db
+
+EDITOR_GROUP = "Aktive"
+
+
+def claims(**overrides):
+ base = {
+ "sub": "42",
+ "preferred_username": "kari",
+ "email": "kari@neuf.test",
+ "given_name": "Kari",
+ "family_name": "Nordmann",
+ "groups": [EDITOR_GROUP, "Styret"],
+ }
+ return {**base, **overrides}
+
+
+@pytest.fixture(autouse=True)
+def oidc_settings(settings):
+ settings.GALTINN_EDITOR_GROUP = EDITOR_GROUP
+ settings.OIDC_RP_CLIENT_ID = "client-id"
+
+
+def link(user, galtinn_id="42"):
+ return GaltinnUser.objects.create(user=user, galtinn_id=galtinn_id)
+
+
+def editor(client, django_user_model, **kwargs):
+ user = django_user_model.objects.create_user(username="editor", password="pw", **kwargs)
+ user.user_permissions.add(Permission.objects.get(codename="access_admin"))
+ client.force_login(user)
+ return user
+
+
+def test_linked_member_is_synced_from_claims():
+ user = User.objects.create_user(username="stale-name", email="stale@example.test")
+ link(user)
+
+ resolved = resolve_user(claims(preferred_username="kari-new"))
+
+ assert resolved.pk == user.pk
+ assert resolved.username == "kari-new"
+ assert resolved.email == "kari@neuf.test"
+ assert resolved.first_name == "Kari"
+
+
+def test_linked_member_is_reactivated_and_added_to_editors():
+ user = User.objects.create_user(username="kari", is_active=False)
+ link(user)
+
+ resolved = resolve_user(claims())
+
+ assert resolved.is_active
+ assert resolved.groups.filter(name=WAGTAIL_EDITORS_GROUP).exists()
+
+
+def test_linked_member_loses_local_password():
+ user = User.objects.create_user(username="kari", password="pw")
+ link(user)
+
+ resolved = resolve_user(claims())
+
+ assert not resolved.has_usable_password()
+
+
+def test_integer_sub_matches_stored_id():
+ user = User.objects.create_user(username="kari")
+ link(user)
+
+ assert resolve_user(claims(sub=42)).pk == user.pk
+
+
+def test_linked_username_collision_refuses_login(caplog):
+ linked = User.objects.create_user(username="kari-old", email="old@example.test")
+ link(linked)
+ other = User.objects.create_user(username="kari", email="other@example.test")
+
+ with caplog.at_level(logging.ERROR), pytest.raises(LoginRefused):
+ resolve_user(claims())
+
+ linked.refresh_from_db()
+ other.refresh_from_db()
+ assert linked.username == "kari-old"
+ assert linked.email == "old@example.test"
+ assert other.email == "other@example.test"
+ assert "resolve manually" in caplog.text
+
+
+def test_linked_non_member_is_deactivated_and_loses_editors():
+ user = User.objects.create_user(username="kari-old", email="old@example.test")
+ user.groups.add(Group.objects.get(name=WAGTAIL_EDITORS_GROUP))
+ link(user)
+
+ with pytest.raises(LoginRefused):
+ resolve_user(claims(groups=["Styret"]))
+
+ user.refresh_from_db()
+ assert not user.is_active
+ assert not user.groups.exists()
+ assert user.username == "kari-old"
+ assert user.email == "old@example.test"
+
+
+def test_linked_non_member_superuser_stays_active():
+ user = User.objects.create_superuser(username="kari", email="old@example.test", password="pw")
+ link(user)
+
+ with pytest.raises(LoginRefused):
+ resolve_user(claims(groups=[]))
+
+ user.refresh_from_db()
+ assert user.is_active
+ assert user.email == "old@example.test"
+ assert user.has_usable_password()
+
+
+@pytest.mark.parametrize(
+ "local",
+ [
+ {"username": "kari", "email": "other@example.test"},
+ {"username": "KARI", "email": "other@example.test"},
+ {"username": "somebody", "email": "kari@neuf.test"},
+ {"username": "somebody", "email": "KARI@neuf.test"},
+ ],
+)
+@pytest.mark.parametrize("groups", [[EDITOR_GROUP], []])
+def test_unlinked_local_user_is_never_matched(local, groups, caplog):
+ user = User.objects.create_superuser(password="pw", **local)
+
+ with caplog.at_level(logging.ERROR), pytest.raises(LoginRefused):
+ resolve_user(claims(groups=groups))
+
+ user.refresh_from_db()
+ assert user.username == local["username"]
+ assert user.email == local["email"]
+ assert user.is_active
+ assert user.has_usable_password()
+ assert User.objects.count() == 1
+ assert not GaltinnUser.objects.exists()
+ assert ("link manually" in caplog.text) == bool(groups)
+
+
+def test_link_is_never_rebound_to_another_galtinn_id():
+ user = User.objects.create_user(username="kari", email="kari@neuf.test")
+ link(user, galtinn_id="7")
+
+ with pytest.raises(LoginRefused):
+ resolve_user(claims())
+
+ assert GaltinnUser.objects.get(user=user).galtinn_id == "7"
+
+
+def test_repeat_login_keeps_single_link():
+ resolve_user(claims())
+ resolve_user(claims())
+
+ assert GaltinnUser.objects.get(galtinn_id="42").user.username == "kari"
+ assert User.objects.count() == 1
+
+
+def test_unknown_member_is_created_with_galtinn_identity():
+ resolved = resolve_user(claims())
+
+ assert resolved.username == "kari"
+ assert resolved.email == "kari@neuf.test"
+ assert resolved.first_name == "Kari"
+ assert not resolved.has_usable_password()
+ assert resolved.groups.filter(name=WAGTAIL_EDITORS_GROUP).exists()
+ assert GaltinnUser.objects.filter(user=resolved, galtinn_id="42").exists()
+
+
+def test_unknown_non_member_is_not_created():
+ with pytest.raises(LoginRefused):
+ resolve_user(claims(groups=["Styret"]))
+
+ assert not User.objects.exists()
+
+
+@pytest.mark.parametrize("name", ["sub", "preferred_username", "email"])
+@pytest.mark.parametrize("value", [None, ""])
+def test_missing_claim_refuses_login(name, value):
+ bad = claims(**{name: value}) if value is not None else claims()
+ if value is None:
+ del bad[name]
+
+ with pytest.raises(LoginRefused):
+ resolve_user(bad)
+
+ assert not User.objects.exists()
+
+
+def test_empty_editor_group_setting_fails_closed(settings):
+ settings.GALTINN_EDITOR_GROUP = ""
+
+ with pytest.raises(LoginRefused):
+ resolve_user(claims())
+
+ assert not User.objects.exists()
+
+
+def test_editors_group_missing_fails_loudly():
+ Group.objects.filter(name=WAGTAIL_EDITORS_GROUP).delete()
+
+ with pytest.raises(Group.DoesNotExist):
+ resolve_user(claims())
+
+ assert not User.objects.exists()
+
+
+def test_login_redirects_to_galtinn_with_configured_callback(client, monkeypatch, settings):
+ settings.WAGTAIL_BASE_URL = "https://cms.example.test"
+ seen = {}
+
+ def authorize_redirect(request, redirect_uri):
+ seen["redirect_uri"] = redirect_uri
+ return HttpResponseRedirect("https://galtinn.example.test/authorize")
+
+ monkeypatch.setattr(oidc.oauth.galtinn, "authorize_redirect", authorize_redirect)
+
+ response = client.get(reverse("oidc_login"), {"next": "/admin/pages/3/"})
+
+ assert response.url == "https://galtinn.example.test/authorize"
+ assert seen["redirect_uri"] == "https://cms.example.test/oidc/callback/"
+ assert client.session["oidc_next"] == "/admin/pages/3/"
+
+
+@pytest.mark.parametrize("setting", ["OIDC_RP_CLIENT_ID", "GALTINN_EDITOR_GROUP"])
+def test_views_are_404_when_not_configured(client, settings, setting):
+ setattr(settings, setting, "")
+
+ assert client.get(reverse("oidc_login")).status_code == 404
+ assert client.get(reverse("oidc_callback")).status_code == 404
+
+
+@pytest.mark.parametrize(
+ ("next_url", "expected"),
+ [("/admin/pages/3/", "/admin/pages/3/"), ("https://evil.example.test/", "/admin/")],
+)
+def test_callback_only_follows_local_next(client, monkeypatch, next_url, expected):
+ monkeypatch.setattr(
+ oidc.oauth.galtinn, "authorize_access_token", lambda request: {"userinfo": claims()}
+ )
+ session = client.session
+ session["oidc_next"] = next_url
+ session.save()
+
+ response = client.get(reverse("oidc_callback"))
+
+ assert response.url == expected
+
+
+@pytest.mark.parametrize(
+ "error", [OAuthError("denied"), JoseError("bad token"), requests.ConnectionError("down")]
+)
+def test_callback_flow_failure_redirects_to_login_with_message(client, monkeypatch, error):
+ def fail(request):
+ raise error
+
+ monkeypatch.setattr(oidc.oauth.galtinn, "authorize_access_token", fail)
+
+ response = client.get(reverse("oidc_callback"), follow=True)
+
+ assert response.redirect_chain[-1][0] == reverse("wagtailadmin_login")
+ assert any("feilet" in str(m) for m in response.context["messages"])
+ assert "_auth_user_id" not in client.session
+
+
+def test_callback_without_userinfo_is_refused(client, monkeypatch):
+ monkeypatch.setattr(oidc.oauth.galtinn, "authorize_access_token", lambda request: {})
+
+ response = client.get(reverse("oidc_callback"), follow=True)
+
+ assert any("ikke tilgang" in str(m) for m in response.context["messages"])
+ assert "_auth_user_id" not in client.session
+
+
+def test_callback_logs_in_and_redirects_to_admin(client, monkeypatch):
+ monkeypatch.setattr(
+ oidc.oauth.galtinn, "authorize_access_token", lambda request: {"userinfo": claims()}
+ )
+
+ response = client.get(reverse("oidc_callback"))
+
+ assert response.status_code == 302
+ assert response.url == reverse("wagtailadmin_home")
+ user = User.objects.get(username="kari")
+ assert client.session["_auth_user_id"] == str(user.pk)
+
+
+def test_callback_refusal_redirects_to_login_with_message(client, monkeypatch):
+ monkeypatch.setattr(
+ oidc.oauth.galtinn,
+ "authorize_access_token",
+ lambda request: {"userinfo": claims(groups=[])},
+ )
+
+ response = client.get(reverse("oidc_callback"), follow=True)
+
+ assert response.redirect_chain[-1][0] == reverse("wagtailadmin_login")
+ messages = [str(m) for m in response.context["messages"]]
+ assert any("ikke tilgang" in m for m in messages)
+ assert "_auth_user_id" not in client.session
+
+
+def test_login_page_shows_galtinn_button_once(client):
+ response = client.get(reverse("wagtailadmin_login"))
+
+ assert response.content.count(reverse("oidc_login").encode()) == 1
+ assert b"Logg inn med lokal bruker" in response.content
+ assert b"login-form" in response.content
+
+
+def test_login_page_is_local_only_when_not_configured(client, settings):
+ settings.OIDC_RP_CLIENT_ID = ""
+
+ html = client.get(reverse("wagtailadmin_login")).content.decode()
+
+ assert reverse("oidc_login") not in html
+ assert "Logg inn med lokal bruker" not in html
+ assert re.search(r'
', html)
+
+
+def test_linked_account_page_shows_galtinn_note_and_disables_fields(client, django_user_model):
+ link(editor(client, django_user_model))
+
+ html = client.get(reverse("wagtailadmin_account")).content.decode()
+
+ assert "Navn og e-post må endres i Galtinn." in html
+ for field in ["first_name", "last_name", "email"]:
+ tag = re.search(rf']*name="name_email-{field}"[^>]*>', html).group(0)
+ assert "disabled" in tag
+
+
+def test_linked_account_post_cannot_change_name_or_email(client, django_user_model):
+ user = editor(client, django_user_model, email="editor@neuf.test", first_name="Ed")
+ link(user)
+
+ client.post(
+ reverse("wagtailadmin_account"),
+ {"name_email-first_name": "Hacked", "name_email-email": "hacked@example.test"},
+ )
+
+ user.refresh_from_db()
+ assert user.first_name == "Ed"
+ assert user.email == "editor@neuf.test"
+
+
+def test_local_account_page_keeps_editable_name_and_email(client, django_user_model):
+ editor(client, django_user_model)
+
+ html = client.get(reverse("wagtailadmin_account")).content.decode()
+
+ assert "Navn og e-post må endres i Galtinn." not in html
+ tag = re.search(r']*name="name_email-email"[^>]*>', html).group(0)
+ assert "disabled" not in tag
+
+
+def test_account_page_requires_login(client):
+ response = client.get(reverse("wagtailadmin_account"))
+
+ assert response.status_code == 302
+ assert reverse("wagtailadmin_login") in response.url
diff --git a/dnscms/uv.lock b/dnscms/uv.lock
index eec7e3b..a574dda 100644
--- a/dnscms/uv.lock
+++ b/dnscms/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 5
requires-python = "==3.14.*"
[[package]]
@@ -29,6 +29,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
]
+[[package]]
+name = "authlib"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "joserfc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" },
+]
+
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
@@ -51,6 +64,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.5.1"
@@ -161,6 +209,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" },
]
+[[package]]
+name = "cryptography"
+version = "50.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
+ { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
+ { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
+ { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
+ { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
+ { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
+ { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
+ { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
+]
+
[[package]]
name = "defusedxml"
version = "0.7.1"
@@ -313,10 +424,12 @@ name = "dnscms"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "authlib" },
{ name = "django" },
{ name = "django-extensions" },
{ name = "gunicorn" },
{ name = "psycopg2-binary" },
+ { name = "requests" },
{ name = "wagtail" },
{ name = "wagtail-grapple" },
{ name = "wagtail-headless-preview" },
@@ -334,10 +447,12 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "authlib", specifier = ">=1.6,<2" },
{ name = "django", specifier = ">=6.1,<7" },
{ name = "django-extensions", specifier = ">=4.1,<5" },
{ name = "gunicorn", specifier = ">=26.2.0,<27" },
{ name = "psycopg2-binary", specifier = ">=2.9.12,<3" },
+ { name = "requests", specifier = ">=2.34,<3" },
{ name = "wagtail", specifier = ">=8.0,<9" },
{ name = "wagtail-grapple", specifier = ">=0.31.0,<0.32" },
{ name = "wagtail-headless-preview", specifier = ">=0.9,<0.10" },
@@ -484,6 +599,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "joserfc"
+version = "1.7.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
+]
+
[[package]]
name = "laces"
version = "0.1.2"
@@ -623,6 +750,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
]
+[[package]]
+name = "pycparser"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.13.5"