From 55fcb4549b4e0ad3fb3e2a1a90da634d8e1dc9f5 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 03:41:13 -0500 Subject: [PATCH 01/12] ee/psso: require biometrics for Secure Enclave key when requested Add a require_biometrics field to RegisterDeviceResponse and have the PSSO bridge set userSecureEnclaveKeyBiometricPolicy to .touchIDOrWatchCurrentSet when the connector requests it. --- .../generated/sys_auth_apple/sys_auth_apple.rs | 2 ++ .../sys_auth_apple/sys_auth_apple.serde.rs | 18 ++++++++++++++++++ ak-sysd/src/components/auth/apple.rs | 5 +++++ .../Bridge/Generated/sys_auth_apple.pb.swift | 9 ++++++++- ee/psso/Bridge/SysdBridge.swift | 8 ++++++++ protobuf/sys_auth_apple.proto | 1 + 6 files changed, 42 insertions(+), 1 deletion(-) diff --git a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs index 9031f476f..64fc7d4ce 100644 --- a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs +++ b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs @@ -41,6 +41,8 @@ pub struct RegisterDeviceResponse { pub nonce_endpoint: ::prost::alloc::string::String, #[prost(string, tag="7")] pub device_token: ::prost::alloc::string::String, + #[prost(bool, tag="8")] + pub require_biometrics: bool, } include!("sys_auth_apple.tonic.rs"); include!("sys_auth_apple.serde.rs"); diff --git a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs index d5faa070c..fcb9506dc 100644 --- a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs +++ b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs @@ -174,6 +174,9 @@ impl serde::Serialize for RegisterDeviceResponse { if !self.device_token.is_empty() { len += 1; } + if self.require_biometrics { + len += 1; + } let mut struct_ser = serializer.serialize_struct("sys_auth_apple.RegisterDeviceResponse", len)?; if !self.client_id.is_empty() { struct_ser.serialize_field("clientId", &self.client_id)?; @@ -196,6 +199,9 @@ impl serde::Serialize for RegisterDeviceResponse { if !self.device_token.is_empty() { struct_ser.serialize_field("deviceToken", &self.device_token)?; } + if self.require_biometrics { + struct_ser.serialize_field("requireBiometrics", &self.require_biometrics)?; + } struct_ser.end() } } @@ -218,6 +224,8 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { "nonceEndpoint", "device_token", "deviceToken", + "require_biometrics", + "requireBiometrics", ]; #[allow(clippy::enum_variant_names)] @@ -229,6 +237,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { Audience, NonceEndpoint, DeviceToken, + RequireBiometrics, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -257,6 +266,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { "audience" => Ok(GeneratedField::Audience), "nonceEndpoint" | "nonce_endpoint" => Ok(GeneratedField::NonceEndpoint), "deviceToken" | "device_token" => Ok(GeneratedField::DeviceToken), + "requireBiometrics" | "require_biometrics" => Ok(GeneratedField::RequireBiometrics), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -283,6 +293,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { let mut audience__ = None; let mut nonce_endpoint__ = None; let mut device_token__ = None; + let mut require_biometrics__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ClientId => { @@ -327,6 +338,12 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { } device_token__ = Some(map_.next_value()?); } + GeneratedField::RequireBiometrics => { + if require_biometrics__.is_some() { + return Err(serde::de::Error::duplicate_field("requireBiometrics")); + } + require_biometrics__ = Some(map_.next_value()?); + } } } Ok(RegisterDeviceResponse { @@ -337,6 +354,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { audience: audience__.unwrap_or_default(), nonce_endpoint: nonce_endpoint__.unwrap_or_default(), device_token: device_token__.unwrap_or_default(), + require_biometrics: require_biometrics__.unwrap_or_default(), }) } } diff --git a/ak-sysd/src/components/auth/apple.rs b/ak-sysd/src/components/auth/apple.rs index 4332a45d7..7c1041fca 100644 --- a/ak-sysd/src/components/auth/apple.rs +++ b/ak-sysd/src/components/auth/apple.rs @@ -50,6 +50,11 @@ pub async fn register_device( jwks_endpoint: res.jwks_endpoint, audience: res.audience, nonce_endpoint: res.nonce_endpoint, + // TEMPORARY: hardcoded for testing. The server-side field exists but is not + // yet in a released authentik-client, so reading `res.require_biometrics` + // does not compile against the pinned client. Swap back to + // `res.require_biometrics` once the API change lands upstream. + require_biometrics: true, // Not part of the API response — the domain's own stored token, // mirroring Go's `device_token: dc.Token`. device_token: active.cfg.token.clone(), diff --git a/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift b/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift index 355bf4caa..bdc928320 100644 --- a/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift +++ b/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift @@ -85,6 +85,8 @@ nonisolated struct RegisterDeviceResponse: Sendable { var deviceToken: String = String() + var requireBiometrics: Bool = false + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -211,7 +213,7 @@ nonisolated extension RegisterDeviceRequest: SwiftProtobuf.Message, SwiftProtobu nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".RegisterDeviceResponse" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}client_id\0\u{1}issuer\0\u{3}token_endpoint\0\u{3}jwks_endpoint\0\u{1}audience\0\u{3}nonce_endpoint\0\u{3}device_token\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}client_id\0\u{1}issuer\0\u{3}token_endpoint\0\u{3}jwks_endpoint\0\u{1}audience\0\u{3}nonce_endpoint\0\u{3}device_token\0\u{3}require_biometrics\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -226,6 +228,7 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob case 5: try { try decoder.decodeSingularStringField(value: &self.audience) }() case 6: try { try decoder.decodeSingularStringField(value: &self.nonceEndpoint) }() case 7: try { try decoder.decodeSingularStringField(value: &self.deviceToken) }() + case 8: try { try decoder.decodeSingularBoolField(value: &self.requireBiometrics) }() default: break } } @@ -253,6 +256,9 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob if !self.deviceToken.isEmpty { try visitor.visitSingularStringField(value: self.deviceToken, fieldNumber: 7) } + if self.requireBiometrics != false { + try visitor.visitSingularBoolField(value: self.requireBiometrics, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } @@ -264,6 +270,7 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob if lhs.audience != rhs.audience {return false} if lhs.nonceEndpoint != rhs.nonceEndpoint {return false} if lhs.deviceToken != rhs.deviceToken {return false} + if lhs.requireBiometrics != rhs.requireBiometrics {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/ee/psso/Bridge/SysdBridge.swift b/ee/psso/Bridge/SysdBridge.swift index bb42d22de..2d43037ff 100644 --- a/ee/psso/Bridge/SysdBridge.swift +++ b/ee/psso/Bridge/SysdBridge.swift @@ -217,6 +217,14 @@ public class SysdBridge { withAllowedCharacters: .alphanumerics) ) ) + // Require Touch ID / Apple Watch for the user Secure Enclave key when the + // authentik connector requests it. userSecureEnclaveKeyBiometricPolicy is an + // OptionSet (AuthenticationServices, macOS 14.4+); .touchIDOrWatchCurrentSet + // invalidates the key if the enrolled biometrics change. Other options: + // .touchIDOrWatchAny, .reuseDuringUnlock, .passwordFallback. + if res.requireBiometrics { + cfg.userSecureEnclaveKeyBiometricPolicy = .touchIDOrWatchCurrentSet + } return cfg } } diff --git a/protobuf/sys_auth_apple.proto b/protobuf/sys_auth_apple.proto index 92d3c858c..e3d2ea7b4 100644 --- a/protobuf/sys_auth_apple.proto +++ b/protobuf/sys_auth_apple.proto @@ -33,4 +33,5 @@ message RegisterDeviceResponse { string audience = 5; string nonce_endpoint = 6; string device_token = 7; + bool require_biometrics = 8; } From 8ecf0fcd0d6257e0566b16afe3ab266f453295e1 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 03:53:13 -0500 Subject: [PATCH 02/12] ak-sysd: log psso register_device response --- ak-sysd/src/components/auth/apple.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ak-sysd/src/components/auth/apple.rs b/ak-sysd/src/components/auth/apple.rs index 7c1041fca..c3093ea25 100644 --- a/ak-sysd/src/components/auth/apple.rs +++ b/ak-sysd/src/components/auth/apple.rs @@ -43,6 +43,16 @@ pub async fn register_device( let res = endpoints_agents_psso_register_device_create(&active.api, body) .await .map_err(|e| Status::internal(format!("psso register_device failed: {e}")))?; + // `res.require_biometrics` can't be logged yet — the field isn't in the + // generated authentik-client, so the hardcoded value below is what actually + // reaches PSSO. Log that instead, and switch to the response value together + // with the `require_biometrics` field below once the client has it. + tracing::info!( + domain = %active.cfg.domain, + require_biometrics = true, + client_id = %res.client_id, + "psso register_device response" + ); Ok(RegisterDeviceResponse { client_id: res.client_id, issuer: res.issuer, From bc0d2f10d264727d0e19673427679b5557afd78d Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 16:53:27 -0500 Subject: [PATCH 03/12] ee/psso: send a valid :authority over the sysd unix socket --- ee/psso/Bridge/SysdBridge.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ee/psso/Bridge/SysdBridge.swift b/ee/psso/Bridge/SysdBridge.swift index 2d43037ff..cc70aad35 100644 --- a/ee/psso/Bridge/SysdBridge.swift +++ b/ee/psso/Bridge/SysdBridge.swift @@ -75,7 +75,14 @@ public class SysdBridge { return try await withGRPCClient( transport: .http2NIOPosix( target: .unixDomainSocket(path: self.getSocketPath(id: id)), - transportSecurity: .plaintext + transportSecurity: .plaintext, + // For a Unix-domain target grpc-swift defaults :authority to the + // percent-encoded socket path, which Rust's h2 rejects as an + // invalid authority with RST_STREAM(PROTOCOL_ERROR) before tonic + // ever sees the request. Pin it to a valid value instead. + config: .defaults { config in + config.http2.authority = "localhost" + } ), interceptors: [self.logInterceptor], handleClient: handleClient, From 137c0831f02b932f7f99a40b0e3938c62b1f6b43 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 17:01:58 -0500 Subject: [PATCH 04/12] ak-sysd: skip token-less domains when selecting the active one --- ak-sysd/src/cfg/domain.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/ak-sysd/src/cfg/domain.rs b/ak-sysd/src/cfg/domain.rs index 7077362e2..70d4e5e10 100644 --- a/ak-sysd/src/cfg/domain.rs +++ b/ak-sysd/src/cfg/domain.rs @@ -212,17 +212,27 @@ impl DomainManager { refresh_interval } - /// First enabled domain — mirrors Go's `dom[0]` shortcut for - /// single-tenant components (ping, auth, directory, device). Do not + /// First enabled domain that has a token — mirrors Go's `dom[0]` shortcut + /// for single-tenant components (ping, auth, directory, device). Do not /// invent smarter "current domain" selection here. + /// + /// The token check matters because `load_managed` adds an MDM-managed + /// domain alongside any user-enrolled ones. If its enrollment produced no + /// token, it is still `enabled`, and returning it means every request goes + /// out as `Bearer+agent ` and comes back 403 "Authentication credentials + /// were not provided" — while a perfectly good domain sits further down the + /// list. pub async fn active(&self) -> Result> { - self.domains + let selected = self + .domains .read() .await .iter() - .find(|d| d.cfg.enabled) + .find(|d| d.cfg.enabled && !d.cfg.token.is_empty()) .cloned() - .ok_or_else(|| eyre!("no enabled domain configured")) + .ok_or_else(|| eyre!("no enabled domain with a token configured"))?; + tracing::debug!(domain = %selected.cfg.domain, "selected active domain"); + Ok(selected) } pub async fn save_domain(&self, cfg: DomainConfig) -> Result<()> { From 672f10b50d992ae4546dad47e767f684426f1a2b Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 18:08:53 -0500 Subject: [PATCH 05/12] ak-sysd: keep the file fallback unless the keyring token reads back --- ak-sysd/src/cfg/domain.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/ak-sysd/src/cfg/domain.rs b/ak-sysd/src/cfg/domain.rs index 70d4e5e10..0f744680f 100644 --- a/ak-sysd/src/cfg/domain.rs +++ b/ak-sysd/src/cfg/domain.rs @@ -248,7 +248,36 @@ impl DomainManager { ) .await { - Ok(_) => on_disk.fallback_token = String::new(), + // A write that cannot be read back is no use to us, so confirm the + // value is retrievable before dropping the on-disk copy. On macOS + // the write succeeds from a launchd daemon but the read fails with + // errSecInteractionNotAllowed, because retrieving the item wants an + // ACL prompt and there is no UI session to show one. Clearing the + // fallback on the strength of the write alone leaves the token + // somewhere sysd can never reach, and every subsequent request goes + // out as `Bearer+agent ` and comes back 403. + Ok(_) => match ak_platform_keyring::store() + .get( + &keyring_service(), + &cfg.domain, + ak_platform_keyring::Accessibility::Always, + ) + .await + { + Ok(stored) if stored == cfg.token => on_disk.fallback_token = String::new(), + Ok(_) => { + on_disk.fallback_token = cfg.token.clone(); + tracing::warn!( + "keyring returned a different token than was written, keeping file fallback" + ); + } + Err(e) => { + on_disk.fallback_token = cfg.token.clone(); + tracing::warn!( + "saved domain token to keyring but could not read it back ({e:?}), keeping file fallback" + ); + } + }, Err(e) => { on_disk.fallback_token = cfg.token.clone(); tracing::warn!( From bdfedef9eea5b31ce555867605298a07c177d7e2 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 18:51:32 -0500 Subject: [PATCH 06/12] ak-sysd: prefer the managed domain, and remove it when its config is gone --- ak-sysd/src/cfg/domain.rs | 44 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/ak-sysd/src/cfg/domain.rs b/ak-sysd/src/cfg/domain.rs index 0f744680f..0d2af8707 100644 --- a/ak-sysd/src/cfg/domain.rs +++ b/ak-sysd/src/cfg/domain.rs @@ -216,22 +216,36 @@ impl DomainManager { /// for single-tenant components (ping, auth, directory, device). Do not /// invent smarter "current domain" selection here. /// + /// An MDM-managed domain wins when there is one: it carries the connector + /// and device group the organisation assigned, whereas a hand-run + /// `domains join` may point somewhere else entirely. Policy beats ad hoc. + /// /// The token check matters because `load_managed` adds an MDM-managed /// domain alongside any user-enrolled ones. If its enrollment produced no /// token, it is still `enabled`, and returning it means every request goes /// out as `Bearer+agent ` and comes back 403 "Authentication credentials /// were not provided" — while a perfectly good domain sits further down the /// list. + /// + /// Note this still only ever returns one domain, while the storage layer + /// happily holds several. Every auth path (`ping`, `auth/*`, `session`, + /// `directory`) calls this and silently ignores the rest; only `device` + /// check-in and `healthcheck_all` fan out. Making multi-domain genuinely + /// work means binding a domain to each of those call sites. pub async fn active(&self) -> Result> { - let selected = self - .domains - .read() - .await + let domains = self.domains.read().await; + let usable = |d: &&Arc| d.cfg.enabled && !d.cfg.token.is_empty(); + let selected = domains .iter() - .find(|d| d.cfg.enabled && !d.cfg.token.is_empty()) + .find(|d| usable(d) && d.cfg.managed) + .or_else(|| domains.iter().find(usable)) .cloned() .ok_or_else(|| eyre!("no enabled domain with a token configured"))?; - tracing::debug!(domain = %selected.cfg.domain, "selected active domain"); + tracing::debug!( + domain = %selected.cfg.domain, + managed = selected.cfg.managed, + "selected active domain" + ); Ok(selected) } @@ -409,11 +423,25 @@ impl DomainManager { /// Loads (or re-enrolls, or removes) the MDM-managed domain. See /// `cfg::managed` for the platform-specific config source. pub async fn load_managed(&self) -> Result<()> { + const MANAGED_DOMAIN_NAME: &str = "ak-mdm-managed"; + let Some(managed) = crate::cfg::managed::load_managed_config()? else { + // No managed config means the profile is gone, so a managed domain + // left behind is stale policy. `load_all` reloads every `*.json` + // regardless of whether the config that created it still exists, so + // without this the domain lingers forever — never refreshed, never + // revoked, and still competing to be the active one. + if self + .domains() + .await + .iter() + .any(|d| d.cfg.domain == MANAGED_DOMAIN_NAME) + { + tracing::info!("managed config absent, removing managed domain"); + self.delete_domain(MANAGED_DOMAIN_NAME).await?; + } return Ok(()); }; - - const MANAGED_DOMAIN_NAME: &str = "ak-mdm-managed"; let existing = self .domains() .await From f1d19fff22a6a231f85a60afff47dddba6fdc2cd Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 18:58:19 -0500 Subject: [PATCH 07/12] ak-sysd: read managed preferences via CFPreferences, not defaults(1) --- Cargo.lock | 2 ++ ak-sysd/Cargo.toml | 4 +++ ak-sysd/src/cfg/managed.rs | 71 ++++++++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5457a326d..4362e11ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,8 @@ dependencies = [ "chrono", "clap", "clap_complete", + "core-foundation", + "core-foundation-sys", "eyre", "hex", "httptest", diff --git a/ak-sysd/Cargo.toml b/ak-sysd/Cargo.toml index ecff838cb..9ffcb82af 100644 --- a/ak-sysd/Cargo.toml +++ b/ak-sysd/Cargo.toml @@ -52,6 +52,10 @@ signal-hook = "0.4" [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2.186" +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +core-foundation-sys = "0.8" + [target.'cfg(windows)'.dependencies] windows-service = "0.8" winreg = { version = "0.56", features = ["serialization-serde"] } diff --git a/ak-sysd/src/cfg/managed.rs b/ak-sysd/src/cfg/managed.rs index b54301895..972fc352f 100644 --- a/ak-sysd/src/cfg/managed.rs +++ b/ak-sysd/src/cfg/managed.rs @@ -9,29 +9,64 @@ pub struct SysdManagedConfig { /// package. Returns `Ok(None)` if there is no managed config source on this /// platform or no value is currently set (both are normal, not errors). #[cfg(target_os = "macos")] -pub fn load_managed_config() -> Result> { - // `defaults read` resolves the same preferences domain - // (`io.goauthentik.platform`) that Go's CFPreferencesCopyAppValue call - // reads from. Shelling out avoids a raw Core Foundation FFI dependency; - // if strict merging of the MDM-managed preferences layer (as opposed to - // this host's local preferences) turns out to matter, replace this with - // a direct CFPreferencesCopyAppValue call instead. - let read = |key: &str| -> Option { - let out = std::process::Command::new("defaults") - .args(["read", "io.goauthentik.platform", key]) - .output() - .ok()?; - if !out.status.success() { +const MANAGED_APP_ID: &str = "io.goauthentik.platform"; + +/// Reads a single MDM-forced string preference. +/// +/// `CFPreferencesCopyAppValue` is the API Go used, and it is the only way to +/// see the managed layer: profile-delivered values live in +/// `/Library/Managed Preferences/.plist`, and `defaults read ` +/// does **not** resolve them — it reports "domain does not exist" on a machine +/// that plainly has the plist. Shelling out therefore made managed enrollment a +/// silent no-op on every managed Mac. +/// +/// `CFPreferencesAppValueIsForced` gates on the value actually being managed. +/// `CopyAppValue` alone returns the *effective* value, which may come from +/// ordinary user defaults — and this value decides which authentik a root +/// daemon enrolls against, so anything not delivered by MDM is ignored. +#[cfg(target_os = "macos")] +fn managed_string(key: &str) -> Option { + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + use core_foundation_sys::base::{CFGetTypeID, CFRelease}; + use core_foundation_sys::preferences::{ + CFPreferencesAppValueIsForced, CFPreferencesCopyAppValue, + }; + use core_foundation_sys::string::CFStringRef; + + let cf_key = CFString::new(key); + let cf_app = CFString::new(MANAGED_APP_ID); + + unsafe { + if CFPreferencesAppValueIsForced(cf_key.as_concrete_TypeRef(), cf_app.as_concrete_TypeRef()) + == 0 + { return None; } - let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if val.is_empty() { None } else { Some(val) } - }; - let Some(url) = read("URL") else { + let value = + CFPreferencesCopyAppValue(cf_key.as_concrete_TypeRef(), cf_app.as_concrete_TypeRef()); + if value.is_null() { + return None; + } + + // Copy* returns +1; we own it from here. + if CFGetTypeID(value) != CFString::type_id() { + CFRelease(value); + tracing::warn!(key, "managed preference is not a string, ignoring"); + return None; + } + let s = CFString::wrap_under_create_rule(value as CFStringRef).to_string(); + if s.is_empty() { None } else { Some(s) } + } +} + +#[cfg(target_os = "macos")] +pub fn load_managed_config() -> Result> { + let Some(url) = managed_string("URL") else { return Ok(None); }; - let Some(registration_token) = read("RegistrationToken") else { + let Some(registration_token) = managed_string("RegistrationToken") else { return Ok(None); }; Ok(Some(SysdManagedConfig { From 693b41dd6da742fc071f3d035eb41df7fa89fa0e Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 21:54:43 -0500 Subject: [PATCH 08/12] ee/psso: apply the full biometric OptionSet, with password fallback --- ee/psso/Bridge/SysdBridge.swift | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/ee/psso/Bridge/SysdBridge.swift b/ee/psso/Bridge/SysdBridge.swift index cc70aad35..a332f58fe 100644 --- a/ee/psso/Bridge/SysdBridge.swift +++ b/ee/psso/Bridge/SysdBridge.swift @@ -224,13 +224,34 @@ public class SysdBridge { withAllowedCharacters: .alphanumerics) ) ) - // Require Touch ID / Apple Watch for the user Secure Enclave key when the - // authentik connector requests it. userSecureEnclaveKeyBiometricPolicy is an - // OptionSet (AuthenticationServices, macOS 14.4+); .touchIDOrWatchCurrentSet - // invalidates the key if the enrolled biometrics change. Other options: - // .touchIDOrWatchAny, .reuseDuringUnlock, .passwordFallback. + // Biometric policy for the user Secure Enclave key. + // userSecureEnclaveKeyBiometricPolicy is an OptionSet + // (AuthenticationServices, macOS 14.4+) with one requirement plus two + // independent modifiers: + // + // .touchIDOrWatchCurrentSet requirement; key is invalidated if the + // enrolled biometrics change + // .touchIDOrWatchAny requirement; any enrolment + // .passwordFallback prompt for the IdP password when Touch ID is + // cancelled, fails, or was never enrolled + // .reuseDuringUnlock reuse the Touch ID presented at unlock + // + // TEMPORARY: authentik now models this as a requirement plus those two + // modifiers, but the wire format still carries a single bool and the + // generated authentik-client has no field for the list, so the set is + // hardcoded here. Edit these lines to test a combination; replace the whole + // block with the values from the response once the proto and client catch up. + // + // Note .passwordFallback is included deliberately. Without it a user whose + // Touch ID is cancelled, failing, or never enrolled cannot use the key at + // all — which is every Mac with no Touch ID hardware. if res.requireBiometrics { - cfg.userSecureEnclaveKeyBiometricPolicy = .touchIDOrWatchCurrentSet + var policy: + ASAuthorizationProviderExtensionLoginConfiguration + .UserSecureEnclaveKeyBiometricPolicy = [] + policy.insert(.touchIDOrWatchCurrentSet) + policy.insert(.passwordFallback) + cfg.userSecureEnclaveKeyBiometricPolicy = policy } return cfg } From 45120a394756350c64ecdc30b964b4630053ce31 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 22:06:07 -0500 Subject: [PATCH 09/12] ee/psso: carry the full biometric policy set from authentik --- .../sys_auth_apple/sys_auth_apple.rs | 46 ++++++- .../sys_auth_apple/sys_auth_apple.serde.rs | 118 +++++++++++++++--- ak-sysd/src/components/auth/apple.rs | 25 ++-- .../Bridge/Generated/sys_auth_apple.pb.swift | 66 +++++++++- ee/psso/Bridge/SysdBridge.swift | 45 ++++--- protobuf/sys_auth_apple.proto | 14 ++- 6 files changed, 254 insertions(+), 60 deletions(-) diff --git a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs index 64fc7d4ce..19a639ae3 100644 --- a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs +++ b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs @@ -41,8 +41,50 @@ pub struct RegisterDeviceResponse { pub nonce_endpoint: ::prost::alloc::string::String, #[prost(string, tag="7")] pub device_token: ::prost::alloc::string::String, - #[prost(bool, tag="8")] - pub require_biometrics: bool, + #[prost(enumeration="register_device_response::BiometricPolicy", repeated, tag="8")] + pub biometric_policies: ::prost::alloc::vec::Vec, +} +/// Nested message and enum types in `RegisterDeviceResponse`. +pub mod register_device_response { + /// Biometric requirements for the user Secure Enclave key. Mirrors the members + /// of Apple's userSecureEnclaveKeyBiometricPolicy OptionSet: one requirement + /// (CURRENT_SET or ANY) plus any number of modifiers. Empty means no biometric + /// policy is applied. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum BiometricPolicy { + Unspecified = 0, + TouchIdOrWatchCurrentSet = 1, + TouchIdOrWatchAny = 2, + ReuseDuringUnlock = 3, + PasswordFallback = 4, + } + impl BiometricPolicy { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "UNSPECIFIED", + Self::TouchIdOrWatchCurrentSet => "TOUCH_ID_OR_WATCH_CURRENT_SET", + Self::TouchIdOrWatchAny => "TOUCH_ID_OR_WATCH_ANY", + Self::ReuseDuringUnlock => "REUSE_DURING_UNLOCK", + Self::PasswordFallback => "PASSWORD_FALLBACK", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "UNSPECIFIED" => Some(Self::Unspecified), + "TOUCH_ID_OR_WATCH_CURRENT_SET" => Some(Self::TouchIdOrWatchCurrentSet), + "TOUCH_ID_OR_WATCH_ANY" => Some(Self::TouchIdOrWatchAny), + "REUSE_DURING_UNLOCK" => Some(Self::ReuseDuringUnlock), + "PASSWORD_FALLBACK" => Some(Self::PasswordFallback), + _ => None, + } + } + } } include!("sys_auth_apple.tonic.rs"); include!("sys_auth_apple.serde.rs"); diff --git a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs index fcb9506dc..94466e2f5 100644 --- a/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs +++ b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs @@ -70,7 +70,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceRequest { type Value = GeneratedField; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "expected one of: {:?}", FIELDS) + write!(formatter, "expected one of: {:?}", &FIELDS) } #[allow(unused_variables)] @@ -174,7 +174,7 @@ impl serde::Serialize for RegisterDeviceResponse { if !self.device_token.is_empty() { len += 1; } - if self.require_biometrics { + if !self.biometric_policies.is_empty() { len += 1; } let mut struct_ser = serializer.serialize_struct("sys_auth_apple.RegisterDeviceResponse", len)?; @@ -199,8 +199,12 @@ impl serde::Serialize for RegisterDeviceResponse { if !self.device_token.is_empty() { struct_ser.serialize_field("deviceToken", &self.device_token)?; } - if self.require_biometrics { - struct_ser.serialize_field("requireBiometrics", &self.require_biometrics)?; + if !self.biometric_policies.is_empty() { + let v = self.biometric_policies.iter().cloned().map(|v| { + register_device_response::BiometricPolicy::try_from(v) + .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", v))) + }).collect::, _>>()?; + struct_ser.serialize_field("biometricPolicies", &v)?; } struct_ser.end() } @@ -224,8 +228,8 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { "nonceEndpoint", "device_token", "deviceToken", - "require_biometrics", - "requireBiometrics", + "biometric_policies", + "biometricPolicies", ]; #[allow(clippy::enum_variant_names)] @@ -237,7 +241,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { Audience, NonceEndpoint, DeviceToken, - RequireBiometrics, + BiometricPolicies, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -250,7 +254,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { type Value = GeneratedField; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "expected one of: {:?}", FIELDS) + write!(formatter, "expected one of: {:?}", &FIELDS) } #[allow(unused_variables)] @@ -266,7 +270,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { "audience" => Ok(GeneratedField::Audience), "nonceEndpoint" | "nonce_endpoint" => Ok(GeneratedField::NonceEndpoint), "deviceToken" | "device_token" => Ok(GeneratedField::DeviceToken), - "requireBiometrics" | "require_biometrics" => Ok(GeneratedField::RequireBiometrics), + "biometricPolicies" | "biometric_policies" => Ok(GeneratedField::BiometricPolicies), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -293,7 +297,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { let mut audience__ = None; let mut nonce_endpoint__ = None; let mut device_token__ = None; - let mut require_biometrics__ = None; + let mut biometric_policies__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ClientId => { @@ -338,11 +342,11 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { } device_token__ = Some(map_.next_value()?); } - GeneratedField::RequireBiometrics => { - if require_biometrics__.is_some() { - return Err(serde::de::Error::duplicate_field("requireBiometrics")); + GeneratedField::BiometricPolicies => { + if biometric_policies__.is_some() { + return Err(serde::de::Error::duplicate_field("biometricPolicies")); } - require_biometrics__ = Some(map_.next_value()?); + biometric_policies__ = Some(map_.next_value::>()?.into_iter().map(|x| x as i32).collect()); } } } @@ -354,13 +358,93 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { audience: audience__.unwrap_or_default(), nonce_endpoint: nonce_endpoint__.unwrap_or_default(), device_token: device_token__.unwrap_or_default(), - require_biometrics: require_biometrics__.unwrap_or_default(), + biometric_policies: biometric_policies__.unwrap_or_default(), }) } } deserializer.deserialize_struct("sys_auth_apple.RegisterDeviceResponse", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for register_device_response::BiometricPolicy { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let variant = match self { + Self::Unspecified => "UNSPECIFIED", + Self::TouchIdOrWatchCurrentSet => "TOUCH_ID_OR_WATCH_CURRENT_SET", + Self::TouchIdOrWatchAny => "TOUCH_ID_OR_WATCH_ANY", + Self::ReuseDuringUnlock => "REUSE_DURING_UNLOCK", + Self::PasswordFallback => "PASSWORD_FALLBACK", + }; + serializer.serialize_str(variant) + } +} +impl<'de> serde::Deserialize<'de> for register_device_response::BiometricPolicy { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "UNSPECIFIED", + "TOUCH_ID_OR_WATCH_CURRENT_SET", + "TOUCH_ID_OR_WATCH_ANY", + "REUSE_DURING_UNLOCK", + "PASSWORD_FALLBACK", + ]; + + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = register_device_response::BiometricPolicy; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + fn visit_i64(self, v: i64) -> std::result::Result + where + E: serde::de::Error, + { + i32::try_from(v) + .ok() + .and_then(|x| x.try_into().ok()) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self) + }) + } + + fn visit_u64(self, v: u64) -> std::result::Result + where + E: serde::de::Error, + { + i32::try_from(v) + .ok() + .and_then(|x| x.try_into().ok()) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self) + }) + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "UNSPECIFIED" => Ok(register_device_response::BiometricPolicy::Unspecified), + "TOUCH_ID_OR_WATCH_CURRENT_SET" => Ok(register_device_response::BiometricPolicy::TouchIdOrWatchCurrentSet), + "TOUCH_ID_OR_WATCH_ANY" => Ok(register_device_response::BiometricPolicy::TouchIdOrWatchAny), + "REUSE_DURING_UNLOCK" => Ok(register_device_response::BiometricPolicy::ReuseDuringUnlock), + "PASSWORD_FALLBACK" => Ok(register_device_response::BiometricPolicy::PasswordFallback), + _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), + } + } + } + deserializer.deserialize_any(GeneratedVisitor) + } +} impl serde::Serialize for RegisterUserRequest { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -423,7 +507,7 @@ impl<'de> serde::Deserialize<'de> for RegisterUserRequest { type Value = GeneratedField; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "expected one of: {:?}", FIELDS) + write!(formatter, "expected one of: {:?}", &FIELDS) } #[allow(unused_variables)] @@ -532,7 +616,7 @@ impl<'de> serde::Deserialize<'de> for RegisterUserResponse { type Value = GeneratedField; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "expected one of: {:?}", FIELDS) + write!(formatter, "expected one of: {:?}", &FIELDS) } #[allow(unused_variables)] diff --git a/ak-sysd/src/components/auth/apple.rs b/ak-sysd/src/components/auth/apple.rs index c3093ea25..a6503267c 100644 --- a/ak-sysd/src/components/auth/apple.rs +++ b/ak-sysd/src/components/auth/apple.rs @@ -1,5 +1,6 @@ use crate::components::SysdContext; use crate::util::to_status; +use ak_platform::generated::sys_auth_apple::register_device_response::BiometricPolicy; use ak_platform::generated::sys_auth_apple::{ RegisterDeviceRequest, RegisterDeviceResponse, RegisterUserRequest, RegisterUserResponse, }; @@ -43,13 +44,21 @@ pub async fn register_device( let res = endpoints_agents_psso_register_device_create(&active.api, body) .await .map_err(|e| Status::internal(format!("psso register_device failed: {e}")))?; - // `res.require_biometrics` can't be logged yet — the field isn't in the - // generated authentik-client, so the hardcoded value below is what actually - // reaches PSSO. Log that instead, and switch to the response value together - // with the `require_biometrics` field below once the client has it. + // TEMPORARY: authentik returns `biometric_policies` on this endpoint, but the + // generated authentik-client has no field for it, so `res.biometric_policies` + // does not compile against the pinned client. Hardcode the set the connector + // is expected to send until the client is regenerated, then replace this with + // the response value and delete the constant. + // + // PasswordFallback is included deliberately: without it a user whose Touch ID + // is cancelled, failing, or never enrolled cannot use the key at all. + let biometric_policies = vec![ + BiometricPolicy::TouchIdOrWatchCurrentSet as i32, + BiometricPolicy::PasswordFallback as i32, + ]; tracing::info!( domain = %active.cfg.domain, - require_biometrics = true, + biometric_policies = ?biometric_policies, client_id = %res.client_id, "psso register_device response" ); @@ -60,11 +69,7 @@ pub async fn register_device( jwks_endpoint: res.jwks_endpoint, audience: res.audience, nonce_endpoint: res.nonce_endpoint, - // TEMPORARY: hardcoded for testing. The server-side field exists but is not - // yet in a released authentik-client, so reading `res.require_biometrics` - // does not compile against the pinned client. Swap back to - // `res.require_biometrics` once the API change lands upstream. - require_biometrics: true, + biometric_policies, // Not part of the API response — the domain's own stored token, // mirroring Go's `device_token: dc.Token`. device_token: active.cfg.token.clone(), diff --git a/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift b/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift index bdc928320..0c8884913 100644 --- a/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift +++ b/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift @@ -85,10 +85,60 @@ nonisolated struct RegisterDeviceResponse: Sendable { var deviceToken: String = String() - var requireBiometrics: Bool = false + var biometricPolicies: [RegisterDeviceResponse.BiometricPolicy] = [] var unknownFields = SwiftProtobuf.UnknownStorage() + /// Biometric requirements for the user Secure Enclave key. Mirrors the members + /// of Apple's userSecureEnclaveKeyBiometricPolicy OptionSet: one requirement + /// (CURRENT_SET or ANY) plus any number of modifiers. Empty means no biometric + /// policy is applied. + nonisolated enum BiometricPolicy: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case touchIDOrWatchCurrentSet // = 1 + case touchIDOrWatchAny // = 2 + case reuseDuringUnlock // = 3 + case passwordFallback // = 4 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .touchIDOrWatchCurrentSet + case 2: self = .touchIDOrWatchAny + case 3: self = .reuseDuringUnlock + case 4: self = .passwordFallback + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .touchIDOrWatchCurrentSet: return 1 + case .touchIDOrWatchAny: return 2 + case .reuseDuringUnlock: return 3 + case .passwordFallback: return 4 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [RegisterDeviceResponse.BiometricPolicy] = [ + .unspecified, + .touchIDOrWatchCurrentSet, + .touchIDOrWatchAny, + .reuseDuringUnlock, + .passwordFallback, + ] + + } + init() {} } @@ -213,7 +263,7 @@ nonisolated extension RegisterDeviceRequest: SwiftProtobuf.Message, SwiftProtobu nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".RegisterDeviceResponse" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}client_id\0\u{1}issuer\0\u{3}token_endpoint\0\u{3}jwks_endpoint\0\u{1}audience\0\u{3}nonce_endpoint\0\u{3}device_token\0\u{3}require_biometrics\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}client_id\0\u{1}issuer\0\u{3}token_endpoint\0\u{3}jwks_endpoint\0\u{1}audience\0\u{3}nonce_endpoint\0\u{3}device_token\0\u{3}biometric_policies\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -228,7 +278,7 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob case 5: try { try decoder.decodeSingularStringField(value: &self.audience) }() case 6: try { try decoder.decodeSingularStringField(value: &self.nonceEndpoint) }() case 7: try { try decoder.decodeSingularStringField(value: &self.deviceToken) }() - case 8: try { try decoder.decodeSingularBoolField(value: &self.requireBiometrics) }() + case 8: try { try decoder.decodeRepeatedEnumField(value: &self.biometricPolicies) }() default: break } } @@ -256,8 +306,8 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob if !self.deviceToken.isEmpty { try visitor.visitSingularStringField(value: self.deviceToken, fieldNumber: 7) } - if self.requireBiometrics != false { - try visitor.visitSingularBoolField(value: self.requireBiometrics, fieldNumber: 8) + if !self.biometricPolicies.isEmpty { + try visitor.visitPackedEnumField(value: self.biometricPolicies, fieldNumber: 8) } try unknownFields.traverse(visitor: &visitor) } @@ -270,8 +320,12 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob if lhs.audience != rhs.audience {return false} if lhs.nonceEndpoint != rhs.nonceEndpoint {return false} if lhs.deviceToken != rhs.deviceToken {return false} - if lhs.requireBiometrics != rhs.requireBiometrics {return false} + if lhs.biometricPolicies != rhs.biometricPolicies {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } + +nonisolated extension RegisterDeviceResponse.BiometricPolicy: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNSPECIFIED\0\u{1}TOUCH_ID_OR_WATCH_CURRENT_SET\0\u{1}TOUCH_ID_OR_WATCH_ANY\0\u{1}REUSE_DURING_UNLOCK\0\u{1}PASSWORD_FALLBACK\0") +} diff --git a/ee/psso/Bridge/SysdBridge.swift b/ee/psso/Bridge/SysdBridge.swift index a332f58fe..8230427a0 100644 --- a/ee/psso/Bridge/SysdBridge.swift +++ b/ee/psso/Bridge/SysdBridge.swift @@ -226,31 +226,28 @@ public class SysdBridge { ) // Biometric policy for the user Secure Enclave key. // userSecureEnclaveKeyBiometricPolicy is an OptionSet - // (AuthenticationServices, macOS 14.4+) with one requirement plus two - // independent modifiers: + // (AuthenticationServices, macOS 14.4+): one requirement + // (.touchIDOrWatchCurrentSet invalidates the key when the enrolled + // biometrics change, .touchIDOrWatchAny does not) plus two independent + // modifiers — .passwordFallback prompts for the IdP password when Touch + // ID is cancelled, fails or was never enrolled, and .reuseDuringUnlock + // reuses the Touch ID presented at unlock. // - // .touchIDOrWatchCurrentSet requirement; key is invalidated if the - // enrolled biometrics change - // .touchIDOrWatchAny requirement; any enrolment - // .passwordFallback prompt for the IdP password when Touch ID is - // cancelled, fails, or was never enrolled - // .reuseDuringUnlock reuse the Touch ID presented at unlock - // - // TEMPORARY: authentik now models this as a requirement plus those two - // modifiers, but the wire format still carries a single bool and the - // generated authentik-client has no field for the list, so the set is - // hardcoded here. Edit these lines to test a combination; replace the whole - // block with the values from the response once the proto and client catch up. - // - // Note .passwordFallback is included deliberately. Without it a user whose - // Touch ID is cancelled, failing, or never enrolled cannot use the key at - // all — which is every Mac with no Touch ID hardware. - if res.requireBiometrics { - var policy: - ASAuthorizationProviderExtensionLoginConfiguration - .UserSecureEnclaveKeyBiometricPolicy = [] - policy.insert(.touchIDOrWatchCurrentSet) - policy.insert(.passwordFallback) + // The set is sent by authentik so the whole OptionSet is configurable; + // an empty list leaves the property untouched. + var policy: + ASAuthorizationProviderExtensionLoginConfiguration + .UserSecureEnclaveKeyBiometricPolicy = [] + for entry in res.biometricPolicies { + switch entry { + case .touchIDOrWatchCurrentSet: policy.insert(.touchIDOrWatchCurrentSet) + case .touchIDOrWatchAny: policy.insert(.touchIDOrWatchAny) + case .reuseDuringUnlock: policy.insert(.reuseDuringUnlock) + case .passwordFallback: policy.insert(.passwordFallback) + case .unspecified, .UNRECOGNIZED: continue + } + } + if !policy.isEmpty { cfg.userSecureEnclaveKeyBiometricPolicy = policy } return cfg diff --git a/protobuf/sys_auth_apple.proto b/protobuf/sys_auth_apple.proto index e3d2ea7b4..a50262eb7 100644 --- a/protobuf/sys_auth_apple.proto +++ b/protobuf/sys_auth_apple.proto @@ -33,5 +33,17 @@ message RegisterDeviceResponse { string audience = 5; string nonce_endpoint = 6; string device_token = 7; - bool require_biometrics = 8; + + // Biometric requirements for the user Secure Enclave key. Mirrors the members + // of Apple's userSecureEnclaveKeyBiometricPolicy OptionSet: one requirement + // (CURRENT_SET or ANY) plus any number of modifiers. Empty means no biometric + // policy is applied. + enum BiometricPolicy { + UNSPECIFIED = 0; + TOUCH_ID_OR_WATCH_CURRENT_SET = 1; + TOUCH_ID_OR_WATCH_ANY = 2; + REUSE_DURING_UNLOCK = 3; + PASSWORD_FALLBACK = 4; + } + repeated BiometricPolicy biometric_policies = 8; } From 1bded3048f6d740ee2c09dd1d2d6d00d78a2e85d Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Wed, 12 Aug 2026 04:21:43 -0500 Subject: [PATCH 10/12] ee/psso: advertise the password grant and register without an enclave key Platform SSO picks the grant from the profile's AuthenticationMethod, so a Password profile needs the password grant advertised or macOS never attempts a login. Advertise both. Registration runs in both modes -- it is what binds the local account to an authentik user -- but only the userSecureEnclaveKey method has a key to register. Thread the method through to RegisterUser and make the key optional instead of force-unwrapping a nil the password mode always produces. --- ee/psso/PSSO/API.swift | 25 ++++++++++++++++--- ee/psso/PSSO/InteractiveAuth.swift | 8 +++++- ee/psso/PSSO/PlatformSSO.swift | 9 +++++-- .../xcshareddata/swiftpm/Package.resolved | 4 +-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/ee/psso/PSSO/API.swift b/ee/psso/PSSO/API.swift index b16ea431a..ca341bc6b 100644 --- a/ee/psso/PSSO/API.swift +++ b/ee/psso/PSSO/API.swift @@ -38,11 +38,30 @@ class API { func RegisterUser( loginManger: ASAuthorizationProviderExtensionLoginManager, userToken: String, + method: ASAuthorizationProviderExtensionAuthenticationMethod, ) async -> ASAuthorizationProviderExtensionRegistrationResult { do { - let (EnclaveKeyID, UserSecureEnclaveKey, _) = try getPublicKeyString( - from: loginManger.key(for: .userSecureEnclaveKey)!)! - self.logger.debug("registering user with sysd...") + // Only the userSecureEnclaveKey method has a user Secure Enclave key to + // register: in password mode macOS never generates one, so asking for it + // returns nil. Registering is still required in both modes -- it is what + // binds this local account to an authentik user -- so the key is optional + // here rather than a precondition. + var EnclaveKeyID = "" + var UserSecureEnclaveKey = "" + if method == .userSecureEnclaveKey { + guard let key = loginManger.key(for: .userSecureEnclaveKey) else { + self.logger.error("no user Secure Enclave key to register") + return .failed + } + guard let (keyID, publicKey, _) = try getPublicKeyString(from: key) else { + self.logger.error("could not read the user Secure Enclave public key") + return .failed + } + EnclaveKeyID = keyID + UserSecureEnclaveKey = publicKey + } + self.logger.debug( + "registering user with sysd, method \(String(describing: method))...") let loginConfig = try await SysdBridge.shared .pssoRegisterUser( enclaveKeyID: EnclaveKeyID, diff --git a/ee/psso/PSSO/InteractiveAuth.swift b/ee/psso/PSSO/InteractiveAuth.swift index b599b0584..75d27a4f0 100644 --- a/ee/psso/PSSO/InteractiveAuth.swift +++ b/ee/psso/PSSO/InteractiveAuth.swift @@ -16,14 +16,19 @@ final class InteractiveAuth: Sendable { private var authState: AKInteractiveAuth? private let loginManager: ASAuthorizationProviderExtensionLoginManager + private let method: ASAuthorizationProviderExtensionAuthenticationMethod private var continuation: CheckedContinuation? var logger: Logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: "InteractiveAuth") - init(loginManager: ASAuthorizationProviderExtensionLoginManager) { + init( + loginManager: ASAuthorizationProviderExtensionLoginManager, + method: ASAuthorizationProviderExtensionAuthenticationMethod, + ) { self.loginManager = loginManager + self.method = method } func cancelAuth() { @@ -53,6 +58,7 @@ final class InteractiveAuth: Sendable { .RegisterUser( loginManger: self.loginManager, userToken: token, + method: self.method, ) } else { return .failed diff --git a/ee/psso/PSSO/PlatformSSO.swift b/ee/psso/PSSO/PlatformSSO.swift index 62e48b1fb..b2c873360 100644 --- a/ee/psso/PSSO/PlatformSSO.swift +++ b/ee/psso/PSSO/PlatformSSO.swift @@ -58,7 +58,7 @@ extension AuthenticationViewController: ASAuthorizationProviderExtensionRegistra self.logger.error("Failed to check if interactive auth is available: \(error)") return .failed } - let interactive = InteractiveAuth(loginManager: loginManager) + let interactive = InteractiveAuth(loginManager: loginManager, method: method) self.interactive = interactive do { return try await interactive.startAuth(viewController: self) ?? .failed @@ -83,7 +83,12 @@ extension AuthenticationViewController: ASAuthorizationProviderExtensionRegistra func supportedGrantTypes() -> ASAuthorizationProviderExtensionSupportedGrantTypes { self.logger.debug("supportedGrantTypes") - return [.jwtBearer] + // Both methods the profile can ask for. macOS picks one according to the + // payload's AuthenticationMethod: jwt-bearer carries an assertion signed by the + // user's Secure Enclave key, password carries the login-window credential as + // claims of the login request. Advertising only jwt-bearer leaves a Password + // profile with no grant it can use, and macOS never attempts a login at all. + return [.jwtBearer, .password] } func keyWillRotate( diff --git a/ee/psso/authentikPlatform.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ee/psso/authentikPlatform.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dd6a6491b..2a04ccbaa 100644 --- a/ee/psso/authentikPlatform.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ee/psso/authentikPlatform.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { From 97ad7e6a412db414bec7069a10dc322ef2b128e0 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Wed, 12 Aug 2026 04:31:52 -0500 Subject: [PATCH 11/12] ak-sysd: list every biometric policy at the hardcode, unused ones commented The set is still hardcoded pending a client-rust regeneration, so it is the knob a test run is configured with. Spell out all four members of Apple's OptionSet with the unused ones commented out, so switching configurations does not mean looking the variants up again, and note which combinations conflict. --- ak-sysd/src/components/auth/apple.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ak-sysd/src/components/auth/apple.rs b/ak-sysd/src/components/auth/apple.rs index a6503267c..48370207f 100644 --- a/ak-sysd/src/components/auth/apple.rs +++ b/ak-sysd/src/components/auth/apple.rs @@ -50,11 +50,24 @@ pub async fn register_device( // is expected to send until the client is regenerated, then replace this with // the response value and delete the constant. // - // PasswordFallback is included deliberately: without it a user whose Touch ID - // is cancelled, failing, or never enrolled cannot use the key at all. + // Every member of Apple's OptionSet is listed so a test run is a matter of + // commenting lines in and out rather than looking the variants up again. Keep at + // most one requirement live: TouchIdOrWatchCurrentSet invalidates the key when the + // enrolled biometrics change, TouchIdOrWatchAny does not, and the two together are + // contradictory. The rest are independent modifiers. + // + // Live set below is the Secure Enclave configuration. It is inert while the + // profile asks for the password method -- macOS only reads this policy when there + // is a user Secure Enclave key to guard -- so it can stay put across both tests. let biometric_policies = vec![ + // Requirement -- pick one. BiometricPolicy::TouchIdOrWatchCurrentSet as i32, + // BiometricPolicy::TouchIdOrWatchAny as i32, + // + // Modifiers. PasswordFallback is live deliberately: without it a user whose + // Touch ID is cancelled, failing, or never enrolled cannot use the key at all. BiometricPolicy::PasswordFallback as i32, + // BiometricPolicy::ReuseDuringUnlock as i32, ]; tracing::info!( domain = %active.cfg.domain, From 6db2e91f793f225a08504c2518b45454c1269ea4 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Wed, 12 Aug 2026 08:18:45 -0500 Subject: [PATCH 12/12] ee/psso: set keyEndpointURL --- ee/psso/Bridge/SysdBridge.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ee/psso/Bridge/SysdBridge.swift b/ee/psso/Bridge/SysdBridge.swift index 8230427a0..b59a42aa7 100644 --- a/ee/psso/Bridge/SysdBridge.swift +++ b/ee/psso/Bridge/SysdBridge.swift @@ -216,6 +216,14 @@ public class SysdBridge { audience: res.audience ) cfg.nonceEndpointURL = URL(string: res.nonceEndpoint)! + // Required for Platform SSO 2.0, alongside protocolVersion() returning + // .version2_0: Apple gates the version on both, and without this the device + // registers as protocolVersion 1. macOS still sends key requests -- they + // fall back to the token endpoint -- and the IdP answers them, but the + // device never enters 2.0 and token binding fails locally afterwards. + // authentik serves key requests from the token endpoint rather than a + // separate one, so this points at the same URL. + cfg.keyEndpointURL = URL(string: res.tokenEndpoint)! cfg.customNonceRequestValues .append( URLQueryItem(