SFAuthorizationPluginView returns nil at the active-session lock screen (screensaver unlock), non-MDM

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

  1. Install the plugin into /Library/Security/SecurityAgentPlugins/.
  2. Define a custom right (class = evaluate-mechanisms, mechanisms = [ OurPlugin:invoke ]) — we do not
    replace the screensaver rule.
  3. 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.)
  4. 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

  1. 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?
  2. 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?
  3. 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.
  4. 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.