enterprise/endpoints/connectors: Fleet conditional access stage (#20978)

* rework mtls stage to be more modular

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

* sync fleet conditional access CA to authentik

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

* save host uuid

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

* initial stage impl

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

* add fixtures & tests

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

* add lookup

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

* migrate to parsing mobileconfig

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

* directly use stage_invalid

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

* add tests

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

* add more test

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

* test team mapping

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

* fix endpoint test

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

* cleanup

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

* Add document for this. Update sidebar.

* Doc improvement

* Add note about Fleet licensing

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* re-fix tests after mtls traefik encoding change

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

* Add info about fleet and device config. Add link from fleet connector doc.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
This commit is contained in:
Jens L.
2026-04-24 15:17:00 +01:00
committed by GitHub
parent c6ee7b6881
commit d1d38edb50
13 changed files with 398 additions and 70 deletions

View File

@@ -1,10 +1,12 @@
from unittest.mock import PropertyMock, patch
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.controller import BaseController
from authentik.endpoints.models import StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.lib.generators import generate_id
@@ -25,16 +27,22 @@ class TestAPI(APITestCase):
)
self.assertEqual(res.status_code, 201)
def test_endpoint_stage_fleet(self):
connector = FleetConnector.objects.create(name=generate_id())
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
def test_endpoint_stage_agent_no_stage(self):
connector = AgentConnector.objects.create(name=generate_id())
class controller(BaseController):
def capabilities(self):
return []
with patch.object(AgentConnector, "controller", PropertyMock(return_value=controller)):
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content, {"connector": ["Selected connector is not compatible with this stage."]}

View File

@@ -1,11 +1,15 @@
import re
from plistlib import loads
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.x509 import load_der_x509_certificate
from django.db import transaction
from requests import RequestException
from rest_framework.exceptions import ValidationError
from authentik.core.models import User
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
from authentik.endpoints.facts import (
DeviceFacts,
@@ -44,7 +48,7 @@ class FleetController(BaseController[DBC]):
return "fleetdm.com"
def capabilities(self) -> list[Capabilities]:
return [Capabilities.ENROLL_AUTOMATIC_API]
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
def _url(self, path: str) -> str:
return f"{self.connector.url}{path}"
@@ -76,8 +80,44 @@ class FleetController(BaseController[DBC]):
except RequestException as exc:
raise ConnectorSyncException(exc) from exc
@property
def mtls_ca_managed(self) -> str:
return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
def _sync_mtls_ca(self):
"""Sync conditional access Root CA for mTLS"""
try:
# Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
# hence we fetch the apple config profile and extract it
res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
res.raise_for_status()
profile = loads(res.text).get("PayloadContent", [])
raw_cert = None
for payload in profile:
if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
continue
raw_cert = payload.get("PayloadContent")
if not raw_cert:
raise ConnectorSyncException("Failed to get conditional acccess CA")
except RequestException as exc:
raise ConnectorSyncException(exc) from exc
cert = load_der_x509_certificate(raw_cert)
CertificateKeyPair.objects.update_or_create(
managed=self.mtls_ca_managed,
defaults={
"name": f"Fleet Endpoint connector {self.connector.name}",
"certificate_data": cert.public_bytes(
encoding=serialization.Encoding.PEM,
).decode("utf-8"),
},
)
@transaction.atomic
def sync_endpoints(self) -> None:
try:
self._sync_mtls_ca()
except ConnectorSyncException as exc:
self.logger.warning("Failed to sync conditional access CA", exc=exc)
for host in self._paginate_hosts():
serial = host["hardware_serial"]
device, _ = Device.objects.get_or_create(
@@ -198,6 +238,8 @@ class FleetController(BaseController[DBC]):
for policy in host.get("policies", [])
],
"agent_version": fleet_version,
# Host UUID is required for conditional access matching
"uuid": host.get("uuid", "").lower(),
},
},
}

View File

@@ -51,6 +51,12 @@ class FleetConnector(Connector):
def component(self) -> str:
return "ak-endpoints-connector-fleet-form"
@property
def stage(self):
from authentik.enterprise.endpoints.connectors.fleet.stage import FleetStageView
return FleetStageView
class Meta:
verbose_name = _("Fleet Connector")
verbose_name_plural = _("Fleet Connectors")

View File

@@ -0,0 +1,51 @@
from cryptography.x509 import (
Certificate,
Extension,
SubjectAlternativeName,
UniformResourceIdentifier,
)
from rest_framework.exceptions import PermissionDenied
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE, MTLSStageView
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
FLEET_CONDITIONAL_ACCESS_URI_PREFIX = "urn:device:apple:uuid:"
class FleetStageView(MTLSStageView):
def get_authorities(self):
stage: EndpointStage = self.executor.current_stage
connector = FleetConnector.objects.filter(pk=stage.connector_id).first()
controller = connector.controller(connector)
kp = CertificateKeyPair.objects.filter(managed=controller.mtls_ca_managed).first()
return [kp] if kp else None
def lookup_device(self, cert: Certificate, mode: StageMode):
san_ext: Extension[SubjectAlternativeName] = cert.extensions.get_extension_for_oid(
SubjectAlternativeName.oid
)
raw_values = san_ext.value.get_values_for_type(UniformResourceIdentifier)
values = [x.removeprefix(FLEET_CONDITIONAL_ACCESS_URI_PREFIX).lower() for x in raw_values]
self.logger.debug("Looking for devices with uuid", fleet_device_uuid=values)
device = Device.objects.filter(
**{"deviceconnection__devicefactsnapshot__data__vendor__fleetdm.com__uuid__in": values}
).first()
if not device and mode == StageMode.REQUIRED:
raise PermissionDenied("Failed to find device")
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
return self.executor.stage_ok()
def dispatch(self, request, *args, **kwargs):
stage: EndpointStage = self.executor.current_stage
try:
cert = self.get_cert(stage.mode)
if not cert:
return self.executor.stage_ok()
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
return self.lookup_device(cert, stage.mode)
except PermissionDenied as exc:
return self.executor.stage_invalid(error_message=exc.detail)

View File

@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDwDCCAqigAwIBAgIBBDANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAi
BgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NF
UCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI2
MDMxODExMTc1NFoXDTI3MDQyMDExMjc1NFowLDEqMCgGA1UEAxMhRmxlZXQgY29u
ZGl0aW9uYWwgYWNjZXNzIGZvciBPa3RhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA3xuKxQQ8JSA4qCJ6RfOB7tbQurhwXiaJSLUDG7R5ncdRcd9LH/9y
5ZyI5kQACOwfICHmv02zR4/CrurfzXabo3CCpvcMdS7JI/FzP1GIIZ5RsR7oPFC6
JJg3m5BHuoHsUtCD7w0D52WiE7XVfbw47h2ChKmGMhkSrBvQnp3dHFEt8ntbl1/q
zCSuQaLeR2sQFurBDVBdinEgsvb1YHaYHi4tdFx5joG64Q/nJXyA2OM4hO9uBF+G
c4UVTzubx5sxwONcPhC9H+eLMpF1VHeU9gAGBlruVusUEYDmlqYQuA+bW5fTr4Zd
ZmJ5e+CzzUBYHduAML9a5S+1jbxSPZFBSwIDAQABo4GvMIGsMA4GA1UdDwEB/wQE
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUPrc1+LvbR9WoJIWZ
7YQa/3IX2w8wHwYDVR0jBBgwFoAUfl92kU2qcH4e+hypez4kEnqMbk4wRQYDVR0R
BD4wPIY6dXJuOmRldmljZTphcHBsZTp1dWlkOjVCRjQyMkQ2LTZFQUItNTE1Ni1B
QzVBLTlFQURDOTUyNDcxMzANBgkqhkiG9w0BAQsFAAOCAQEAGfxJ/u4271tnUUTB
J39YU6z2Ciav+9G3BtbvxBXI57Po7zCE6Z1sVkvYq6Xd0CcItPWRjbSPEy78ZzS0
By+gPy5fkKc8HHJ5I1wK890xbLBUS1P4EbdVBzI9ggouEa3B2asE10asnzLoKE4C
0FYWQwrzCsso8yxsJj1S8RKtd6MMbCis/9OQSC8om2tu6cLO+OftVn5DHtNWFidw
tAl/oHn2cZPUfZGpJGrHNZlp5w1c1dYfQeiPayoQIbsF+8eMV424G76z/8UPhMBs
R23LByv4TlUOPAGn2TRa2WtLIXs7FgqXRIFW4CjsPsEpXSVNlkYcn/VHY7Jl13zz
CRQ1Pg==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<!-- Trusted CA certificate -->
<dict>
<key>PayloadCertificateFileName</key>
<string>conditional_access_ca.der</string>
<key>PayloadContent</key>
<data>MIIDjzCCAnegAwIBAgIBATANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAiBgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NFUCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI1MTIwOTEyMjI1MVoXDTM1MTIwOTEyMjI1MVowaTEJMAcGA1UEBhMAMSQwIgYDVQQKExtMb2NhbCBjZXJ0aWZpY2F0ZSBhdXRob3JpdHkxEDAOBgNVBAsTB1NDRVAgQ0ExJDAiBgNVBAMTG0ZsZWV0IGNvbmRpdGlvbmFsIGFjY2VzcyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANrgCcpzQci2UhH+Dn0eHopnnbx3HbMabMCHXm6xteMVFLrdQJDTFrZCQzcexUgbpPJ0az6mn4szo+E3stn0y2PPWsiAiVhFwp5M9HwNg18rPgDmITv2pM3l/hlEsfggjq6TEVO2gRcq4NujEGagcYX6kp6nWxh6bbRngQ/hlK6mXItWV3x0G9eTcbFObwZhbuC2dNbccytdqbVEIpBjp6fftQnQwAaUVjoyZBFlf1C1cDV4+1jpaVsIj11U1olA33GJCHcZQ4CJEsgh8yiSsvkH5RNf94CGINB5ixsMfppjSXV/vNkWDKEfmUXW2q4ft7KK/L/SRq8QSB4VqTAp2GsCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH5fdpFNqnB+HvocqXs+JBJ6jG5OMA0GCSqGSIb3DQEBCwUAA4IBAQAJr4bTGlrANoHStu4Y+OXjGbEQjZOe546Bcln4eWrEB16eaVzfKuZgjJYdcOmp36/v34QY/OCXEIsixrBU5aW/Sr53IK6UQSZV3O3xbBc4Aert7AbeJ4NVGZyelfVQo/5G0qM6k9p0+zpIZqNAzFbhcSPIzuE7ig2OGsFoQU+bXhzk09bsZ+u4BXibzVNfMuMG+DHNv0PRjll272nEPI3bGwHF5tdrnfJG6e9t+qK9j9UqmSlBknHQJNeU5o8IDcmWYjWtOuBzecYsg8pZzXabJqlHTBIz/h7waRe7jtrK+XopK3jghRf9JTL+i0Y8NbVjoNkIoS3xMeRhnNbR9lw1</data>
<key>PayloadDescription</key>
<string>Fleet conditional access CA certificate</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access CA</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-ca</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>ef1b2231-ad80-5511-9893-1f9838295147</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Configures SCEP enrollment for Okta conditional access</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access for Okta</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-okta</string>
<key>PayloadOrganization</key>
<string>Fleet Device Management</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>User</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>6fa509a3-feca-56f7-a283-d6a81c733ed2</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

View File

@@ -1,27 +1,27 @@
{
"created_at": "2025-06-25T22:21:35Z",
"updated_at": "2025-12-20T11:42:09Z",
"created_at": "2026-02-18T16:31:34Z",
"updated_at": "2026-03-18T11:29:18Z",
"software": null,
"software_updated_at": "2025-10-22T02:24:25Z",
"id": 1,
"detail_updated_at": "2025-10-23T23:30:31Z",
"label_updated_at": "2025-10-23T23:30:31Z",
"policy_updated_at": "2025-10-23T23:02:11Z",
"last_enrolled_at": "2025-06-25T22:21:37Z",
"seen_time": "2025-10-23T23:59:08Z",
"software_updated_at": "2026-03-18T11:29:17Z",
"id": 19,
"detail_updated_at": "2026-03-18T11:29:18Z",
"label_updated_at": "2026-03-18T11:29:18Z",
"policy_updated_at": "2026-03-18T11:29:18Z",
"last_enrolled_at": "2026-02-18T16:31:45Z",
"seen_time": "2026-03-18T11:31:34Z",
"refetch_requested": false,
"hostname": "jens-mac-vm.local",
"uuid": "C8B98348-A0A6-5838-A321-57B59D788269",
"uuid": "5BF422D6-6EAB-5156-AC5A-9EADC9524713",
"platform": "darwin",
"osquery_version": "5.19.0",
"osquery_version": "5.21.0",
"orbit_version": null,
"fleet_desktop_version": null,
"scripts_enabled": null,
"os_version": "macOS 26.0.1",
"build": "25A362",
"os_version": "macOS 26.3",
"build": "25D125",
"platform_like": "darwin",
"code_name": "",
"uptime": 256356000000000,
"uptime": 653014000000000,
"memory": 4294967296,
"cpu_type": "arm64e",
"cpu_subtype": "ARM64E",
@@ -31,38 +31,41 @@
"hardware_vendor": "Apple Inc.",
"hardware_model": "VirtualMac2,1",
"hardware_version": "",
"hardware_serial": "Z5DDF07GK6",
"hardware_serial": "ZV35VFDD50",
"computer_name": "jens-mac-vm",
"timezone": null,
"public_ip": "92.116.179.252",
"primary_ip": "192.168.85.3",
"primary_mac": "e6:9d:21:c2:2f:19",
"primary_ip": "192.168.64.7",
"primary_mac": "5e:72:1c:89:98:29",
"distributed_interval": 10,
"config_tls_refresh": 60,
"logger_tls_period": 10,
"team_id": 2,
"team_id": 5,
"pack_stats": null,
"team_name": "prod",
"gigs_disk_space_available": 23.82,
"percent_disk_space_available": 37,
"team_name": "dev",
"gigs_disk_space_available": 16.52,
"percent_disk_space_available": 26,
"gigs_total_disk_space": 62.83,
"gigs_all_disk_space": null,
"issues": {
"failing_policies_count": 1,
"critical_vulnerabilities_count": 2,
"total_issues_count": 3
"critical_vulnerabilities_count": 0,
"total_issues_count": 1
},
"device_mapping": null,
"mdm": {
"enrollment_status": "On (manual)",
"dep_profile_error": false,
"server_url": "https://fleet.beryjuio-home.k8s.beryju.io/mdm/apple/mdm",
"server_url": "https://fleet.beryjuio-prod.k8s.beryju.io/mdm/apple/mdm",
"name": "Fleet",
"encryption_key_available": false,
"connected_to_fleet": true
},
"refetch_critical_queries_until": null,
"last_restarted_at": "2025-10-21T00:17:55Z",
"status": "offline",
"last_restarted_at": "2026-03-10T22:05:44.00887Z",
"status": "online",
"display_text": "jens-mac-vm.local",
"display_name": "jens-mac-vm"
"display_name": "jens-mac-vm",
"fleet_id": 5,
"fleet_name": "dev"
}

View File

@@ -21,12 +21,19 @@ TEST_HOST = {"hosts": [TEST_HOST_UBUNTU, TEST_HOST_MACOS, TEST_HOST_WINDOWS, TES
class TestFleetConnector(APITestCase):
def setUp(self):
self.connector = FleetConnector.objects.create(
name=generate_id(), url="http://localhost", token=generate_id()
name=generate_id(),
url="http://localhost",
token=generate_id(),
map_teams_access_group=True,
)
def test_sync(self):
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json=TEST_HOST,
@@ -40,6 +47,9 @@ class TestFleetConnector(APITestCase):
identifier="VMware-56 4d 4a 5a b0 22 7b d7-9b a5 0b dc 8f f2 3b 60"
).first()
self.assertIsNotNone(device)
group = device.access_group
self.assertIsNotNone(group)
self.assertEqual(group.name, "prod")
self.assertEqual(
device.cached_facts.data,
{
@@ -50,7 +60,13 @@ class TestFleetConnector(APITestCase):
"version": "24.04.3 LTS",
},
"disks": [],
"vendor": {"fleetdm.com": {"policies": [], "agent_version": ""}},
"vendor": {
"fleetdm.com": {
"policies": [],
"agent_version": "",
"uuid": "5a4a4d56-22b0-d77b-9ba5-0bdc8ff23b60",
}
},
"network": {"hostname": "ubuntu-desktop", "interfaces": []},
"hardware": {
"model": "VMware20,1",
@@ -72,6 +88,10 @@ class TestFleetConnector(APITestCase):
self.connector.save()
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json=TEST_HOST,
@@ -81,11 +101,13 @@ class TestFleetConnector(APITestCase):
json={"hosts": []},
)
controller.sync_endpoints()
self.assertEqual(mock.call_count, 2)
self.assertEqual(mock.call_count, 3)
self.assertEqual(mock.request_history[0].method, "GET")
self.assertEqual(mock.request_history[0].headers["foo"], "bar")
self.assertEqual(mock.request_history[1].method, "GET")
self.assertEqual(mock.request_history[1].headers["foo"], "bar")
self.assertEqual(mock.request_history[2].method, "GET")
self.assertEqual(mock.request_history[2].headers["foo"], "bar")
def test_map_host_linux(self):
controller = self.connector.controller(self.connector)
@@ -128,6 +150,6 @@ class TestFleetConnector(APITestCase):
"arch": "arm64e",
"family": OSFamily.macOS,
"name": "macOS",
"version": "26.0.1",
"version": "26.3",
},
)

View File

@@ -0,0 +1,84 @@
from json import loads
from ssl import PEM_FOOTER, PEM_HEADER
from django.urls import reverse
from requests_mock import Mocker
from authentik.core.tests.utils import (
create_test_flow,
)
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class FleetConnectorStageTests(FlowTestCase):
def setUp(self):
super().setUp()
self.connector = FleetConnector.objects.create(
name=generate_id(), url="http://localhost", token=generate_id()
)
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json={"hosts": [loads(load_fixture("fixtures/host_macos.json"))]},
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=1&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json={"hosts": []},
)
controller.sync_endpoints()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.stage = EndpointStage.objects.create(
name=generate_id(),
mode=StageMode.REQUIRED,
connector=self.connector,
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.host_cert = load_fixture("fixtures/cond_acc_host.pem")
def _format_traefik(self, cert: str | None = None):
cert = cert if cert else self.host_cert
return cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", "")
def test_assoc(self):
dev = Device.objects.get(identifier="ZV35VFDD50")
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
)
self.assertEqual(res.status_code, 200)
plan = plan()
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], dev)
self.assertEqual(
plan.context[PLAN_CONTEXT_CERTIFICATE]["subject"],
"CN=Fleet conditional access for Okta",
)
def test_assoc_not_found(self):
dev = Device.objects.get(identifier="ZV35VFDD50")
dev.delete()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
plan = plan()
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)

View File

@@ -15,6 +15,7 @@ from cryptography.x509 import (
)
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import PermissionDenied
from authentik.brands.models import Brand
from authentik.core.models import User
@@ -25,7 +26,6 @@ from authentik.enterprise.stages.mtls.models import (
MutualTLSStage,
UserAttributes,
)
from authentik.flows.challenge import AccessDeniedChallenge
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
@@ -217,8 +217,7 @@ class MTLSStageView(ChallengeStageView):
return None
return str(_cert_attr[0])
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
def get_cert(self, mode: StageMode):
certs = [
*self._parse_cert_xfcc(),
*self._parse_cert_nginx(),
@@ -228,21 +227,26 @@ class MTLSStageView(ChallengeStageView):
authorities = self.get_authorities()
if not authorities:
self.logger.warning("No Certificate authority found")
if stage.mode == StageMode.OPTIONAL:
return self.executor.stage_ok()
if stage.mode == StageMode.REQUIRED:
return super().dispatch(request, *args, **kwargs)
if mode == StageMode.OPTIONAL:
return None
if mode == StageMode.REQUIRED:
raise PermissionDenied("Unknown error")
cert = self.validate_cert(authorities, certs)
if not cert and stage.mode == StageMode.REQUIRED:
if not cert and mode == StageMode.REQUIRED:
self.logger.warning("Client certificate required but no certificates given")
return super().dispatch(
request,
*args,
error_message=_("Certificate required but no certificate was given."),
**kwargs,
)
if not cert and stage.mode == StageMode.OPTIONAL:
raise PermissionDenied(str(_("Certificate required but no certificate was given.")))
if not cert and mode == StageMode.OPTIONAL:
self.logger.info("No certificate given, continuing")
return None
return cert
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
try:
cert = self.get_cert(stage.mode)
except PermissionDenied as exc:
return self.executor.stage_invalid(error_message=exc.detail)
if not cert:
return self.executor.stage_ok()
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
existing_user = self.check_if_user(cert)
@@ -251,15 +255,5 @@ class MTLSStageView(ChallengeStageView):
elif existing_user:
self.auth_user(existing_user, cert)
else:
return super().dispatch(
request, *args, error_message=_("No user found for certificate."), **kwargs
)
return self.executor.stage_invalid(_("No user found for certificate."))
return self.executor.stage_ok()
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
return AccessDeniedChallenge(
data={
"component": "ak-stage-access-denied",
"error_message": str(error_message or "Unknown error"),
}
)

View File

@@ -37,4 +37,4 @@ Follow these instructions to configure the Fleet connector in authentik:
The **Map teams to device access group** setting will not detect changes to a device's groups membership in Fleet. If the device's groups change, you will need to manually configure a [device access group](../../authentik-agent/device-authentication/device-access-groups.mdx).
:::
After creating the connector, it can be used in the [Endpoint Stage](../../../add-secure-apps/flows-stages/stages/endpoint/index.md). Refer to [Device compliance policy](../device-compliance-policy.md) for more information on using device facts from the connector in a flow.
After creating the connector, it can be used in the [Endpoint Stage](../../../add-secure-apps/flows-stages/stages/endpoint/index.md). Refer to [Fleet conditional access](../fleet-conditional-access.md) and [Device compliance policy](../device-compliance-policy.md) for more information on using device facts from the connector in a flow.

View File

@@ -0,0 +1,48 @@
---
title: Fleet conditional access for Apple devices
sidebar_label: Fleet conditional access
tags: [device compliance, compliance, conditional access, fleet, fleetdm]
authentik_version: "2026.5"
---
authentik can be configured to restrict access to specific services so that only Fleet-registered Apple devices are allowed.
authentik automatically retrieves the Conditional Access Root CA certificate from Fleet via the Fleet connector. The Endpoint stage then verifies the devices Fleet-issued certificate against this Root CA. If validation succeeds, the device is bound to the users current authentik session.
## Prerequisites
- You must have [configured compliance](./configuration.md) in authentik
- The [Fleet connector](./connectors/fleetdm.md) must be configured in authentik
- Conditional access Root CA Certificate must be pulled from Fleet via the Fleet connector. This is an automatic process
- A Fleet Enterprise license is required
## Configure Fleet and devices
A Fleet Conditional Access configuration profile must be applied to every device that you wish to apply conditional access to. Please note that that this will only function on iOS and iPadOS devices that are enrolled via Apple Automated Device Enrollment (ADE). The same limitation does not apply to macOS devices.
1. Log in to your Fleet dashboard as an administrator.
2. Navigate to **Settings** > **Integrations** > **Conditional Access**.
3. Next to **Okta**, click **Connect**. Despite being named **Okta**, the same profile is used for all integrations.
4. Click the copy button to the right of **User scope profile**.
5. Save the text as a `.mobileconfig` file. Apply it as a Fleet configuration profile on any Apple device that you wish to apply conditional access to.
Refer to the [Fleet Custom OS settings documentation](https://fleetdm.com/guides/custom-os-settings) for more information on applying configuration profiles.
## Configure authentik
This configuration applies to a specific flow, such as an authentication flow.
### Bind Endpoint stage to flow
The flow must have an [Endpoint stage](../../add-secure-apps/flows-stages/stages/endpoint/index.md) bound to it.
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages > Flows**.
3. Select the flow that you want to modify.
4. Open the **Stage Bindings** tab and click **Create and bind stage**.
5. Select **Endpoint Stage** as the stage type, click **Next**, and configure the following settings:
- **Name**: provide a name for the stage
- **Connector**: select the Fleet connector
- **Mode**: set to `Device required`
6. Click **Next**.
7. Select the order for the stage. Ensure that this places the Endpoint stage in the flow wherever you want device access to be checked.
8. Click **Finish**.

View File

@@ -876,6 +876,7 @@ const items = [
"endpoint-devices/device-compliance/device-reporting",
"endpoint-devices/device-compliance/device-compliance-policy",
"endpoint-devices/device-compliance/browser-extension",
"endpoint-devices/device-compliance/fleet-conditional-access",
],
},
],