What we're trying to do
We ship a Developer-ID authorization plugin (a SecurityAgent AuthorizationPlugin bundle exporting
AuthorizationPluginCreate, hosting an SFAuthorizationPluginView subclass) that presents custom
credential + MFA UI at macOS authentication surfaces — the Jamf Connect / NoMAD / XCreds pattern. This
works correctly at the login window (system.login.console).
We now want the same custom UI at the active-session lock screen (screensaver unlock), so a managed
user can satisfy an MFA step on unlock — without MDM. We understand Platform SSO is the MDM-based
path; we're specifically asking about the non-MDM AuthorizationPlugin path, which Cisco Duo and RSA
appear to ship on macOS 14/15.
Environment
- macOS Sonoma 14.8.7 and Sequoia 15.7.7 / 15.7.9, Apple Silicon (arm64).
- Non-MDM. Standard Developer ID distribution, no configuration profiles.
- Plugin bundle: Developer ID Application + Hardened Runtime (flags=runtime), satisfies its DR,
notarized. - Bundle has an empty NSPrincipalClass and no NSMainNibFile; construction follows the NameAndPassword
DTS sample exactly (alloc the SFAuthorizationPluginView subclass via
-initWithCallbacks:andEngineRef: in MechanismInvoke, then -displayView).
Reproduction
- Install the plugin into /Library/Security/SecurityAgentPlugins/.
- Define a custom right (class = evaluate-mechanisms, mechanisms = [ OurPlugin:invoke ]) — we do not
replace the screensaver rule. - Append that right to the existing rule, keeping use-login-window-ui:
system.login.screensaver (class = rule):
rule = [ use-login-window-ui, com.example.screensaver-mfa ]
(This "append after use-login-window-ui" arming matches what Cisco Duo's installer does — native
password unlock runs first, then the appended mechanism runs in the post-password phase.) - Lock, then unlock.
What we observe
Our mechanism is invoked at unlock (MechanismInvoke runs — so the arming is correct; the modern path
otherwise never invokes third-party plugins). But inside MechanismInvoke, on the main thread, in
SecurityAgentHelper-arm64:
host check: AgentPluginViewHelper = PRESENT // we're in the SecurityAgent GUI host
probe: callbacks->version = 4, GetHintValue = <ptr> // engine callbacks, unmodified
probe: authorize-right PRESENT rc=0 val=system.login.screensaver
presenter alloc'd p = 0x0 (nil = 1) // init returns nil
-[SFAuthorizationPluginView initWithCallbacks:andEngineRef:] returns nil. Every downstream symptom
we'd chased earlier (black screen, spinner, "ViewBridge … Connection invalid") is just a consequence
of there being no view object.
What we've ruled out
Tracing the construction path (-initWithCallbacks:andEngineRef: → -loadPlugin → [AgentPluginViewHelper
loadPluginForRight:engineRef:pluginView:]), we've eliminated:
- Host process — AgentPluginViewHelper is present; we're in SecurityAgent, not authorizationhost (our
UI mechanism is non-privileged). - The authorize-right hint — present and correct (system.login.screensaver), read straight through
callbacks->GetHintValue. - Callbacks / engineRef — valid, version == 4, passed through unmodified (same pointer the engine
handed us). - The nib — no NSMainNibFile set.
- The right→product mapping — the right maps to the builtin product, whose plugin object constructs
unconditionally.
That leaves the display_view re-host step, which appears bound to whether this SecurityAgent instance
currently has a live, connection-accepting view-hosting session. Our hypothesis: use-login-window-ui
completes the native unlock and tears down its view-hosting session, so our appended mechanism runs
after teardown → no live host for display_view → the builtin display mechanism's init fails →
SFAuthorizationPluginView init returns nil.
The experiment that should have fixed it — but didn't
The vendor that ships this (Duo) brackets its non-privileged GUI mechanism with a leading privileged
mechanism (and a trailing one). A privileged mechanism runs in authorizationhost; we hypothesized the
privileged→non-privileged transition makes authd re-activate SecurityAgent and stand up a fresh
view-hosting session before the GUI mechanism runs.
We mirrored exactly that:
[ OurPlugin:precheck,privileged, OurPlugin:invoke, OurPlugin:complete,privileged ]
where precheck/complete are no-op privileged mechanisms that just SetResult(allow). The logs confirm
the transition happened: precheck ran in authorizationhosthelper, then invoke ran in a freshly spawned
SecurityAgentHelper process (so SecurityAgent did re-activate), with AgentPluginViewHelper present
and the hint correct — yet SFAuthorizationPluginView init still returned nil.
So replicating the vendor's authorizationdb structure is not sufficient. The remaining difference is
either an OS precondition that simply isn't met during a locked-session unlock (the agent isn't
accepting view-hosting connections in that context), or something in the vendor's bundle/build
(entitlements, embedded XPC/frameworks) rather than the authdb wiring.
Questions
- Is presenting a custom SFAuthorizationPluginView at the active-session lock screen a supported
scenario for a non-MDM Developer-ID authorization plugin on macOS 14/15? - If yes — what is the supported way to ensure a live view-hosting session exists for a mechanism
appended to system.login.screensaver after use-login-window-ui? Is the leading-privileged-mechanism
pattern intended, or is there a documented right/API we should use? - If SFAuthorizationPluginView init returning nil in this context is expected, please confirm and
point us to the sanctioned alternative for custom credential UI at screensaver unlock without MDM. - Does any of this differ on Managed vs. non-Managed devices, or depend on SIP / FileVault / Secure
Enclave state?
References
- Forums 110667 — use-login-window-ui blocks third-party plugins; legacy path invokes them ("not a
bug"). - Forums 744211, 771114 — SFAuthorizationPluginView / ViewBridge / viewForType: never called at the
lock screen on macOS 15. - Feedback FB13238136, FB13128730 — third-party plugin at the lock shield; Data-Protection keychain
from screensaver context. - Sample code: NameAndPassword — our construction shape matches it exactly.
I can provide a minimal reproducer bundle and a sysdiagnose + unified-log capture around a failed
unlock on request.