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-platform/src/generated/sys_auth_apple/sys_auth_apple.rs b/ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs index 9031f476f..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,6 +41,50 @@ pub struct RegisterDeviceResponse { pub nonce_endpoint: ::prost::alloc::string::String, #[prost(string, tag="7")] pub device_token: ::prost::alloc::string::String, + #[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 d5faa070c..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,6 +174,9 @@ impl serde::Serialize for RegisterDeviceResponse { if !self.device_token.is_empty() { len += 1; } + if !self.biometric_policies.is_empty() { + 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,13 @@ impl serde::Serialize for RegisterDeviceResponse { if !self.device_token.is_empty() { struct_ser.serialize_field("deviceToken", &self.device_token)?; } + 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() } } @@ -218,6 +228,8 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { "nonceEndpoint", "device_token", "deviceToken", + "biometric_policies", + "biometricPolicies", ]; #[allow(clippy::enum_variant_names)] @@ -229,6 +241,7 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { Audience, NonceEndpoint, DeviceToken, + BiometricPolicies, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -241,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)] @@ -257,6 +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), + "biometricPolicies" | "biometric_policies" => Ok(GeneratedField::BiometricPolicies), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -283,6 +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 biometric_policies__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ClientId => { @@ -327,6 +342,12 @@ impl<'de> serde::Deserialize<'de> for RegisterDeviceResponse { } device_token__ = Some(map_.next_value()?); } + GeneratedField::BiometricPolicies => { + if biometric_policies__.is_some() { + return Err(serde::de::Error::duplicate_field("biometricPolicies")); + } + biometric_policies__ = Some(map_.next_value::>()?.into_iter().map(|x| x as i32).collect()); + } } } Ok(RegisterDeviceResponse { @@ -337,12 +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(), + 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 @@ -405,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)] @@ -514,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/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/domain.rs b/ak-sysd/src/cfg/domain.rs index 7077362e2..0d2af8707 100644 --- a/ak-sysd/src/cfg/domain.rs +++ b/ak-sysd/src/cfg/domain.rs @@ -212,17 +212,41 @@ 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. + /// + /// 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> { - 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) + .find(|d| usable(d) && d.cfg.managed) + .or_else(|| domains.iter().find(usable)) .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, + managed = selected.cfg.managed, + "selected active domain" + ); + Ok(selected) } pub async fn save_domain(&self, cfg: DomainConfig) -> Result<()> { @@ -238,7 +262,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!( @@ -370,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 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 { diff --git a/ak-sysd/src/components/auth/apple.rs b/ak-sysd/src/components/auth/apple.rs index 4332a45d7..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,6 +44,24 @@ 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}")))?; + // 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, + biometric_policies = ?biometric_policies, + client_id = %res.client_id, + "psso register_device response" + ); Ok(RegisterDeviceResponse { client_id: res.client_id, issuer: res.issuer, @@ -50,6 +69,7 @@ pub async fn register_device( jwks_endpoint: res.jwks_endpoint, audience: res.audience, nonce_endpoint: res.nonce_endpoint, + 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 355bf4caa..0c8884913 100644 --- a/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift +++ b/ee/psso/Bridge/Generated/sys_auth_apple.pb.swift @@ -85,8 +85,60 @@ nonisolated struct RegisterDeviceResponse: Sendable { var deviceToken: String = String() + 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() {} } @@ -211,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") + 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() { @@ -226,6 +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.decodeRepeatedEnumField(value: &self.biometricPolicies) }() default: break } } @@ -253,6 +306,9 @@ nonisolated extension RegisterDeviceResponse: SwiftProtobuf.Message, SwiftProtob if !self.deviceToken.isEmpty { try visitor.visitSingularStringField(value: self.deviceToken, fieldNumber: 7) } + if !self.biometricPolicies.isEmpty { + try visitor.visitPackedEnumField(value: self.biometricPolicies, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } @@ -264,7 +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.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 bb42d22de..8230427a0 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, @@ -217,6 +224,32 @@ public class SysdBridge { withAllowedCharacters: .alphanumerics) ) ) + // Biometric policy for the user Secure Enclave key. + // userSecureEnclaveKeyBiometricPolicy is an OptionSet + // (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. + // + // 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 92d3c858c..a50262eb7 100644 --- a/protobuf/sys_auth_apple.proto +++ b/protobuf/sys_auth_apple.proto @@ -33,4 +33,17 @@ message RegisterDeviceResponse { string audience = 5; string nonce_endpoint = 6; string device_token = 7; + + // 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; }