enterprise/providers/ssf: more conformance fixes (#21521)

* enterprise/providers/ssf: more conformance fixes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include request when possible

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove null state

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* t

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-gen & format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove None state

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix ci

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* revert a thing

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix ssf conformance test

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* no subtest

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix network

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add test for stream update

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2026-05-04 14:11:21 +02:00
committed by GitHub
parent 685f920de2
commit 4851179522
17 changed files with 301 additions and 50 deletions
+6 -4
View File
@@ -282,10 +282,12 @@ jobs:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
- name: oidc_basic
glob: tests/openid_conformance/test_oidc_basic.py
- name: oidc_implicit
glob: tests/openid_conformance/test_oidc_implicit.py
- name: ssf_transmitter
glob: tests/openid_conformance/test_ssf_transmitter.py
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
@@ -1,6 +1,7 @@
# Generated by Django 5.2.12 on 2026-04-04 16:58
from django.db import migrations, models
import django.contrib.postgres.fields
class Migration(migrations.Migration):
@@ -40,4 +41,109 @@ class Migration(migrations.Migration):
]
),
),
migrations.AlterField(
model_name="stream",
name="events_requested",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(
choices=[
(
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"Caep Session Revoked",
),
(
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
"Caep Token Claims Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
"Caep Credential Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
"Caep Assurance Level Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
"Caep Device Compliance Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-established",
"Caep Session Established",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
"Caep Session Presented",
),
(
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
"Caep Risk Level Change",
),
(
"https://schemas.openid.net/secevent/ssf/event-type/verification",
"Set Verification",
),
]
),
default=list,
size=None,
),
),
migrations.AlterField(
model_name="stream",
name="status",
field=models.TextField(
choices=[
("enabled", "Enabled"),
("paused", "Paused"),
("disabled", "Disabled"),
("disabled_deleted", "Disabled Deleted"),
],
default="enabled",
),
),
migrations.AlterField(
model_name="streamevent",
name="type",
field=models.TextField(
choices=[
(
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"Caep Session Revoked",
),
(
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
"Caep Token Claims Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
"Caep Credential Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
"Caep Assurance Level Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
"Caep Device Compliance Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-established",
"Caep Session Established",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
"Caep Session Presented",
),
(
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
"Caep Risk Level Change",
),
(
"https://schemas.openid.net/secevent/ssf/event-type/verification",
"Set Verification",
),
]
),
),
]
@@ -24,8 +24,31 @@ class EventTypes(models.TextChoices):
"""SSF Event types supported by authentik"""
CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.1"""
CAEP_TOKEN_CLAIMS_CHANGE = (
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.2"""
CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.3"""
CAEP_ASSURANCE_LEVEL_CHANGE = (
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.4"""
CAEP_DEVICE_COMPLIANCE_CHANGE = (
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.5"""
CAEP_SESSION_ESTABLISHED = (
"https://schemas.openid.net/secevent/caep/event-type/session-established"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.6"""
CAEP_SESSION_PRESENTED = "https://schemas.openid.net/secevent/caep/event-type/session-presented"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.7"""
CAEP_RISK_LEVEL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.8"""
SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification"
"""https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-8.1.4.1"""
class DeliveryMethods(models.TextChoices):
@@ -46,10 +69,12 @@ class SSFEventStatus(models.TextChoices):
class StreamStatus(models.TextChoices):
"""SSF Stream status"""
ENABLED = "enabled"
PAUSED = "paused"
DISABLED = "disabled"
DISABLED_DELETED = "disabled_deleted"
class SSFProvider(TasksModel, BackchannelProvider):
+2 -2
View File
@@ -108,13 +108,13 @@ def send_ssf_event(stream_uuid: UUID, event_data: dict[str, Any]):
event.save()
self.info("Event successfully sent", status=response.status_code)
# Cleanup, if we were the last pending message for this stream and it has been deleted
# (status=StreamStatus.DISABLED), then we can delete the stream
# (status=StreamStatus.DISABLED_DELETED), then we can delete the stream
if (
not StreamEvent.objects.filter(
stream=stream,
status__in=[SSFEventStatus.PENDING_FAILED, SSFEventStatus.PENDING_NEW],
).exists()
and stream.status == StreamStatus.DISABLED
and stream.status == StreamStatus.DISABLED_DELETED
):
LOGGER.info(
"Deleting inactive stream as all pending messages were sent.", stream=stream
@@ -62,7 +62,7 @@ class TestSSFAuth(APITestCase):
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
self.assertEqual(
event.payload["events"],
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
)
def test_stream_add_oidc(self):
@@ -115,7 +115,7 @@ class TestSSFAuth(APITestCase):
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
self.assertEqual(
event.payload["events"],
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
)
def test_token_invalid(self):
@@ -54,7 +54,7 @@ class TestStream(APITestCase):
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
self.assertEqual(
event.payload["events"],
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
)
def test_stream_add_poll(self):
@@ -96,7 +96,7 @@ class TestStream(APITestCase):
)
self.assertEqual(res.status_code, 204)
stream.refresh_from_db()
self.assertEqual(stream.status, StreamStatus.DISABLED)
self.assertEqual(stream.status, StreamStatus.DISABLED_DELETED)
def test_stream_get(self):
"""get stream"""
@@ -225,3 +225,26 @@ class TestStream(APITestCase):
HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}",
)
self.assertEqual(res.status_code, 404)
def test_stream_status_update(self):
stream = Stream.objects.create(provider=self.provider)
res = self.client.post(
reverse(
"authentik_providers_ssf:stream-status",
kwargs={"application_slug": self.application.slug},
),
data={
"stream_id": str(stream.pk),
"status": StreamStatus.DISABLED,
},
HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}",
)
self.assertEqual(res.status_code, 200)
stream.refresh_from_db()
self.assertJSONEqual(
res.content,
{
"stream_id": str(stream.pk),
"status": str(stream.status),
},
)
@@ -33,7 +33,7 @@ class TestTasks(APITestCase):
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -46,7 +46,7 @@ class TestTasks(APITestCase):
)
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
def test_push_auth(self):
auth = generate_id()
@@ -58,7 +58,7 @@ class TestTasks(APITestCase):
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -72,7 +72,7 @@ class TestTasks(APITestCase):
)
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
def test_push_stream_disable(self):
auth = generate_id()
@@ -81,11 +81,11 @@ class TestTasks(APITestCase):
delivery_method=DeliveryMethods.RFC_PUSH,
endpoint_url="http://localhost/ssf-push",
authorization_header=auth,
status=StreamStatus.DISABLED,
status=StreamStatus.DISABLED_DELETED,
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -95,7 +95,7 @@ class TestTasks(APITestCase):
).get_result(block=True, timeout=1)
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
self.assertFalse(Stream.objects.filter(pk=stream.pk).exists())
def test_push_error(self):
@@ -106,7 +106,7 @@ class TestTasks(APITestCase):
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -24,10 +24,10 @@ class SSFView(APIView):
class SSFStreamView(SSFView):
def get_object(self, any_status=False) -> Stream:
streams = Stream.objects.filter(provider=self.provider)
if not any_status:
streams = streams.filter(status__in=[StreamStatus.ENABLED, StreamStatus.PAUSED])
def get_object(self) -> Stream:
streams = Stream.objects.filter(provider=self.provider).exclude(
status=StreamStatus.DISABLED_DELETED
)
if "stream_id" in self.request.query_params:
streams = streams.filter(pk=self.request.query_params["stream_id"])
if "stream_id" in self.request.data:
@@ -1,6 +1,6 @@
from uuid import uuid4
from django.http import HttpRequest
from django.http import Http404, HttpRequest
from django.urls import reverse
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
@@ -106,7 +106,11 @@ class StreamResponseSerializer(PassiveSerializer):
}
def get_events_supported(self, instance: Stream) -> list[str]:
return [x.value for x in EventTypes]
return [
EventTypes.CAEP_SESSION_REVOKED,
EventTypes.CAEP_CREDENTIAL_CHANGE,
EventTypes.SET_VERIFICATION,
]
class StreamView(SSFStreamView):
@@ -128,10 +132,9 @@ class StreamView(SSFStreamView):
LOGGER.info("Sending verification event", stream=instance)
send_ssf_events(
EventTypes.SET_VERIFICATION,
{
"state": None,
},
{},
stream_filter={"pk": instance.uuid},
request=request,
sub_id={"format": "opaque", "id": str(instance.uuid)},
)
response = StreamResponseSerializer(instance=instance, context={"request": request}).data
@@ -159,7 +162,9 @@ class StreamView(SSFStreamView):
def delete(self, request: Request, *args, **kwargs) -> Response:
stream = self.get_object()
stream.status = StreamStatus.DISABLED
if stream.status == StreamStatus.DISABLED_DELETED:
raise Http404
stream.status = StreamStatus.DISABLED_DELETED
stream.save()
return Response(status=204)
@@ -175,6 +180,7 @@ class StreamVerifyView(SSFStreamView):
"state": state,
},
stream_filter={"pk": stream.uuid},
request=request,
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
return Response(status=204)
@@ -182,8 +188,25 @@ class StreamVerifyView(SSFStreamView):
class StreamStatusView(SSFStreamView):
class StreamStatusSerializer(PassiveSerializer):
stream_id = CharField()
status = ChoiceField(choices=StreamStatus.choices)
def get(self, request: Request, *args, **kwargs):
stream = self.get_object(any_status=True)
stream = self.get_object()
return Response(
{
"stream_id": str(stream.pk),
"status": str(stream.status),
}
)
def post(self, request: Request, *args, **kwargs):
stream = self.get_object()
serializer = self.StreamStatusSerializer(stream, data=request.data)
serializer.is_valid(raise_exception=True)
stream.status = serializer.validated_data["status"]
stream.save()
return Response(
{
"stream_id": str(stream.pk),
+12
View File
@@ -19,8 +19,20 @@
export const EventsRequestedEnum = {
HttpsSchemasOpenidNetSeceventCaepEventTypeSessionRevoked:
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
HttpsSchemasOpenidNetSeceventCaepEventTypeTokenClaimsChange:
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
HttpsSchemasOpenidNetSeceventCaepEventTypeCredentialChange:
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
HttpsSchemasOpenidNetSeceventCaepEventTypeAssuranceLevelChange:
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
HttpsSchemasOpenidNetSeceventCaepEventTypeDeviceComplianceChange:
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
HttpsSchemasOpenidNetSeceventCaepEventTypeSessionEstablished:
"https://schemas.openid.net/secevent/caep/event-type/session-established",
HttpsSchemasOpenidNetSeceventCaepEventTypeSessionPresented:
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
HttpsSchemasOpenidNetSeceventCaepEventTypeRiskLevelChange:
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
HttpsSchemasOpenidNetSeceventSsfEventTypeVerification:
"https://schemas.openid.net/secevent/ssf/event-type/verification",
UnknownDefaultOpenApi: "11184809",
+1
View File
@@ -20,6 +20,7 @@ export const SSFStreamStatusEnum = {
Enabled: "enabled",
Paused: "paused",
Disabled: "disabled",
DisabledDeleted: "disabled_deleted",
UnknownDefaultOpenApi: "11184809",
} as const;
export type SSFStreamStatusEnum = (typeof SSFStreamStatusEnum)[keyof typeof SSFStreamStatusEnum];
+7
View File
@@ -39031,7 +39031,13 @@ components:
EventsRequestedEnum:
enum:
- https://schemas.openid.net/secevent/caep/event-type/session-revoked
- https://schemas.openid.net/secevent/caep/event-type/token-claims-change
- https://schemas.openid.net/secevent/caep/event-type/credential-change
- https://schemas.openid.net/secevent/caep/event-type/assurance-level-change
- https://schemas.openid.net/secevent/caep/event-type/device-compliance-change
- https://schemas.openid.net/secevent/caep/event-type/session-established
- https://schemas.openid.net/secevent/caep/event-type/session-presented
- https://schemas.openid.net/secevent/caep/event-type/risk-level-change
- https://schemas.openid.net/secevent/ssf/event-type/verification
type: string
ExpiringBaseGrantModel:
@@ -55618,6 +55624,7 @@ components:
- enabled
- paused
- disabled
- disabled_deleted
type: string
Schedule:
type: object
+14 -13
View File
@@ -1,6 +1,7 @@
from os import makedirs
from pathlib import Path
from time import sleep
from typing import Any
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
@@ -64,27 +65,27 @@ class TestOpenIDConformance(SeleniumTestCase):
"client_registration": "static_client",
}
def run_test(self, test_name: str, test_plan_config: dict):
# Create a Conformance instance...
def run_test(
self, test_name: str, test_plan_config: dict[str, Any], test_variant: dict[str, Any]
):
self.conformance = Conformance(f"https://{self.host}:8443/", None, verify_ssl=False)
test_plan = self.conformance.create_test_plan(
test_name,
test_plan_config,
self.test_variant,
test_variant,
)
plan_id = test_plan["id"]
for test in test_plan["modules"]:
with self.subTest(test["testModule"], **test["variant"]):
# Fetch name and variant of the next test to run
module_name = test["testModule"]
variant = test["variant"]
module_instance = self.conformance.create_test_from_plan_with_variant(
plan_id, module_name, variant
)
module_id = module_instance["id"]
self.run_single_test(module_id)
self.conformance.wait_for_state(module_id, ["FINISHED"], timeout=self.wait_timeout)
# Fetch name and variant of the next test to run
module_name = test["testModule"]
variant = test["variant"]
module_instance = self.conformance.create_test_from_plan_with_variant(
plan_id, module_name, variant
)
module_id = module_instance["id"]
self.run_single_test(module_id)
self.conformance.wait_for_state(module_id, ["FINISHED"], timeout=self.wait_timeout)
sleep(2)
self.conformance.export_html(plan_id, Path(__file__).parent / "exports")
+4 -4
View File
@@ -2,14 +2,14 @@ services:
mongodb:
image: mongo:6.0.13
nginx:
image: ghcr.io/beryju/oidc-conformance-suite-nginx:v5.1.41
image: ghcr.io/beryju/oidc-conformance-suite-nginx:v5.1.43
ports:
- "8443:8443"
- "8444:8444"
depends_on:
- server
server:
image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.41
image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.43
ports:
- "9999:9999"
extra_hosts:
@@ -19,8 +19,8 @@ services:
-Xdebug -Xrunjdwp:transport=dt_socket,address=*:9999,server=y,suspend=n
-jar /server/fapi-test-suite.jar
-Djdk.tls.maxHandshakeMessageSize=65536
--fintechlabs.base_url=https://host.docker.internal:8443
--fintechlabs.base_mtls_url=https://host.docker.internal:8444
--fintechlabs.base_url=https://localhost:8443
--fintechlabs.base_mtls_url=https://localhost:8444
--fintechlabs.devmode=true
--fintechlabs.startredir=true
links:
@@ -6,5 +6,6 @@ class TestOpenIDConformanceBasic(TestOpenIDConformance):
@retry()
def test_oidcc_basic_certification_test(self):
test_plan_name = "oidcc-basic-certification-test-plan"
self.run_test(test_plan_name, self.test_plan_config)
self.run_test(
"oidcc-basic-certification-test-plan", self.test_plan_config, self.test_variant
)
@@ -6,5 +6,6 @@ class TestOpenIDConformanceImplicit(TestOpenIDConformance):
@retry()
def test_oidcc_implicit_certification_test_plan(self):
test_plan_name = "oidcc-implicit-certification-test-plan"
self.run_test(test_plan_name, self.test_plan_config)
self.run_test(
"oidcc-implicit-certification-test-plan", self.test_plan_config, self.test_variant
)
@@ -0,0 +1,49 @@
from authentik.core.models import Application
from authentik.crypto.models import CertificateKeyPair
from authentik.enterprise.providers.ssf.models import SSFProvider
from authentik.lib.generators import generate_id
from tests.decorators import retry
from tests.live import SSLLiveMixin
from tests.openid_conformance.base import TestOpenIDConformance
class TestOpenIDConformanceSSFTransmitter(TestOpenIDConformance, SSLLiveMixin):
def setUp(self):
super().setUp()
self.provider = SSFProvider.objects.create(
name=generate_id(),
signing_key=CertificateKeyPair.objects.get(name="authentik Self-signed Certificate"),
backchannel_application=Application.objects.get(slug="oidc-conformance-1"),
push_verify_certificates=False,
)
@retry()
def test_openid_ssf_transmitter_test_plan(self):
iss = self.url(
"authentik_providers_ssf:configuration",
application_slug="oidc-conformance-1",
)
self.run_test(
"openid-ssf-transmitter-test-plan",
{
"alias": "authentik",
"description": "authentik",
"ssf": {
"transmitter": {
"issuer": iss,
"configuration_metadata_endpoint": iss,
"access_token": self.provider.token.key,
}
},
},
test_variant={
"client_auth_type": "client_secret_post",
"ssf_server_metadata": "static",
"server_metadata": "static",
"ssf_auth_mode": "static",
"ssf_delivery_mode": "push",
"ssf_profile": "caep_interop",
"client_registration": "static_client",
},
)