From 55fcb4549b4e0ad3fb3e2a1a90da634d8e1dc9f5 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 11 Aug 2026 03:41:13 -0500 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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; }