ee/wcp: separate service account to run browser - #1380
Open
BeryJu wants to merge 38 commits into
Open
Conversation
BROWSER_PRIVILEGE.md was deleted an hour after being written, in an unrelated "cleanup" commit on ee/wcp/rs-cef-fresh. Restore it and record what's settled for this branch: the dedicated-account option goes first, integrated auth is out of scope so S4U needs no stored credential, and the CEF sandbox option turns out to need a C++ shim cef-rs doesn't provide on Windows -- left out of scope here.
ak_cef.exe launched as SYSTEM on fresh logon because acquire_interactive_token fell back to duplicating winlogon.exe's own token when no user token existed yet. Replace it with an S4U logon (LsaLogonUser + MSV1_0_S4U_LOGON) for a dedicated local account, so logon and unlock both mint a token for the same non-SYSTEM identity and the winlogon-duplication fallback goes away entirely. Also grants that account access to WinSta0\Winlogon (denied to any non-SYSTEM token by default) and denies it interactive/network/RDP logon rights, and rotates its installer-set password to a random, discarded value the first time it runs -- nothing ever reads it back, since S4U needs no credential. The account itself is created by the installer; that and the design tradeoffs are in BROWSER_PRIVILEGE.md.
WiX's util:User has no GeneratePassword attribute, so the placeholder password/rotation approach differs from the first draft -- correct that, and update the identity table and "rest of the work" list to describe what credprovider now actually does instead of what was planned.
Adds the local account ak_cef.exe now runs as (credprovider's service_account_token/BROWSER_PRIVILEGE.md), created at install time via util:User with a placeholder password credprovider rotates away on first load, and grants it write access to wcp-cache. Verified with a full local `dotnet build` of the MSI (placeholder binaries, ICE validation included) -- only the wcp component's own binaries are still missing from this tree.
Left over from the SYSTEM-token era this branch replaced: the spawned browser now carries the service account's S4U token, not this process's own SYSTEM one.
BeryJu
marked this pull request as ready for review
August 16, 2026 11:24
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1380 +/- ##
==========================================
- Coverage 47.58% 46.53% -1.06%
==========================================
Files 195 195
Lines 18003 18346 +343
Branches 13138 13481 +343
==========================================
- Hits 8567 8537 -30
- Misses 9199 9577 +378
+ Partials 237 232 -5 Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
The two things CI cannot reach -- the WinSta0\Winlogon ACL grant holding and the S4U service-account token working for logon as well as unlock -- now have a real run behind them rather than an argument. Keeps the ACL-narrowing and temp/fonts/crashpad items open. One VM not failing is not the same as either being handled. Also commits the checklist edits the run was done against: registering by hand skips the MSI's util:User, so the service account has to exist first, and step 5 no longer describes a token fallback that S4U replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch's earlier rebase onto main replayed these commits onto main's actual (simpler) syscalls.rs/credential.rs -- the one merged via #1374/#1378 -- but syscalls.rs still carried PasswordStore, KeyringPasswordStore, ak_platform_keyring and the richer LocalAccountPassword trait from a different, unmerged branch (ee/wcp/stored-pw) that this branch was never meant to depend on. credential.rs never had that machinery to begin with here, so the mismatch didn't compile (LocalAccountPasswordReset vs LocalAccountPassword, missing ak_platform_keyring). Rebuilds the service-account additions directly on main's actual LocalAccountPasswordReset/reset-only API instead. No behavior change to any of this branch's own logic -- ensure_service_account_password_rotated still calls the same single reset, just through the trait that actually exists here.
Contributor
|
🚀 Deployed on https://pr-1380--authentik-pkg.netlify.app |
# Conflicts: # ee/wcp/credprovider/src/syscalls.rs # ee/wcp/e2e/README.md
LocalAccountPasswordReset was this branch's stand-in for the trait name before #1379 (properly reusing a stored password for the interactive user's account) landed on main; merging main back in restored its real name, LocalAccountPassword.
$ is the WiX preprocessor's variable-substitution leader (\$(var.X), \$(env.X)) and this file is preprocessed before the XML is parsed -- plausible cause of the "invalid password" failure at install, since the literal value NetUserAdd ends up receiving may not be what the source says. Also lengthened it as a hedge against a stricter than default minimum-length policy on the install target. No functional change either way: the account is denied all interactive/network/RDP logon and credprovider rotates this away on first load, so the exact value has never mattered past satisfying account creation.
Several ran two paragraphs restating things BROWSER_PRIVILEGE.md already covers, or spelling out both a function's rationale and its caller's in one place. Same information, less of it per comment.
`net accounts` on the install target shows minimum password length 0
and no maximum, ruling out length as the cause of "invalid password" --
so the previous two fixes (dropping "$", lengthening it) were treating
the wrong symptom. NetUserAdd folds LSA password-filter rejections
(complexity, banned-word/dictionary lists) into the same
NERR_PasswordTooShort a real length failure produces, and the old
placeholder contained a real word ("Placeholder"). Replaced it with a
random string with no recognizable words at all.
# Conflicts: # ee/wcp/credprovider/src/ipc.rs # ee/wcp/credprovider/src/syscalls.rs # ee/wcp/e2e/README.md
- ipc.rs lost its ForegroundControl import in conflict resolution: RealSyscalls.foreground_pid() didn't compile without the trait in scope. - e2e/README.md's manual checklist kept both sides' "confirm a fresh logon" step -- the old one referencing syscalls::acquire_interactive_token, which this branch deleted. Dropped the stale duplicate.
# Conflicts: # vpkg/windows/Package.wxs
…need Both LsaRegisterLogonProcess and CreateProcessAsUserW check the privileges they need (SE_TCB_NAME, and SE_ASSIGNPRIMARYTOKEN_NAME/ SE_INCREASE_QUOTA_NAME respectively) as enabled, not merely present -- SYSTEM's token holds all three but disabled by default, same as any privilege that isn't SE_PRIVILEGE_ENABLED_BY_DEFAULT. Generalizes the one-off TCB-enabling helper into enable_privilege(name) and calls it for all three, fixing STATUS_PORT_CONNECTION_REFUSED from the LSA connect and ERROR_NOT_ALL_ASSIGNED from CreateProcessAsUserW seen on a real test build. Corrects the CreateProcessAsUserW retry comment, which had assumed this was unreachable on the real logon scenarios.
PsExec -s confirms SeTcbPrivilege is Enabled for SYSTEM on the test box, contradicting a real ERROR_NOT_ALL_ASSIGNED failure inside service_account_token -- the deployed process's token evidently isn't the plain SYSTEM token that gives you, and the previous error message gave no way to tell a genuinely-missing privilege from an unexpected identity. enable_privilege now takes a display name and logs it, plus the account current_token_identity() resolves via GetTokenInformation(TokenUser)/LookupAccountSidW, on the ERROR_NOT_ALL_ASSIGNED path.
S4U (LsaLogonUser/MSV1_0_S4U_LOGON) needed SE_TCB_NAME, and a real install proved LogonUI's own token does not hold it -- not disabled, absent, confirmed by dumping the token's account identity in the same failure. That's expected once you look at why the credential provider model has hosted providers in the separate, more sandboxed LogonUI.exe since Vista: third-party code loaded there was never meant to have TCB-level trust. Calling LsaRegisterLogonProcess directly from inside one was never going to be supported. Chromium's own credential provider (GCPW) hits the identical wall for the identical reason and has never used S4U: CreateLogonToken in chrome/credential_provider/gaiacp/gcp_utils.cc calls LogonUserW with a real password, then strips every privilege from the result via CreateRestrictedToken(DISABLE_MAX_PRIVILEGE) before using it. service_account_token now does the same. LogonUserW needs no special privilege for a non-admin account -- confirmed independently, since RealSyscalls::validate already called it successfully with no privilege-enabling for the interactive user's password check. This means the service account needs a real, stored password after all -- service_account_password is the same establish-once/validate/ reuse/change state machine as credential.rs::account_password (LOCAL_PASSWORD.md), backed by the same KeyringPasswordStore, keyed by this account's own SID. The installer grants LogonAsService... no, LogonAsBatchJob="yes" for the SeBatchLogonRight LOGON32_LOGON_BATCH needs, matching GCPW's own choice for its non-interactive case. BROWSER_PRIVILEGE.md updated throughout; the Kerberos/SPNEGO scoping question S4U used to raise no longer applies, since LogonUserW tokens carry real credentials.
deny_interactive_and_network_logon's doc still said Service (the old S4U SECURITY_LOGON_TYPE) instead of Batch (LOGON32_LOGON_BATCH, what service_account_token actually uses now).
CreateProcessAsUserW needs SE_ASSIGNPRIMARYTOKEN_NAME/SE_INCREASE_QUOTA_NAME, which turn out to be absent from LogonUI's token, not just disabled - confirmed on a real install (ERROR_PRIVILEGE_NOT_HELD). CreateProcessWithTokenW is brokered through the Secondary Logon service instead and needs only SE_IMPERSONATE_NAME, which is present. It has no handle-inheritance mechanism though, so the result/cancel pipe pair between credprovider and ak_cef.exe is now a named pair (SDDL-scoped to SYSTEM + the service account's SID) instead of anonymous-and-inherited. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pipe DACL hardcoded "SY" (SYSTEM) as the fallback identity when no connecting SID is given, but CPUS_CREDUI's child inherits the caller's own token (spawn_in_current_session passes none), which is not necessarily SYSTEM - the debug/e2e path runs as whatever user launched the test. Fixed by dropping the explicit DACL entirely in that case and letting CreateNamedPipeW apply the default one derived from the creating token, which always grants that same identity full control. connect_duplex_pipes also used to block the full timeout even after the child had already exited (e.g. CEF's own ProcessSingleton/ContentMainRun failing before ak_cef.exe ever reaches the code that opens these pipes) - it now polls in short slices and bails out as soon as WaitForSingleObject shows the process gone, logging the exit code instead of just timing out silently 15 seconds later. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CefInitialize was failing on real installs with Chromium's own
ProcessSingleton error ("Failed to create a ProcessSingleton for your
profile directory ... Aborting now to avoid profile corruption").
root_cache_path doubles as Chromium's user-data directory, which is what
ProcessSingleton locks against, and every launch shared one fixed path -
a second sign-in attempt starting before the first one's process had fully
torn down (or any leftover instance) made every subsequent launch fail
outright.
Fixed by giving each launch its own unique subdirectory
(browser_state_dir) instead of the shared one, removed again once that
run is done (wipe_browser_state, best-effort - a launch the credential
provider had to kill for never responding just leaves its directory
behind).
Supersedes the "try without root cache path" experiment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CreateProcessWithTokenW was called with lpEnvironment: None, which reuses this process's own (SYSTEM/LogonUI) environment for the child rather than the token's. LOGON_WITH_PROFILE only loads the account's registry hive; it does not touch the environment block, which is a separate thing the caller builds. So %TEMP%/%LOCALAPPDATA%/etc. in the child still pointed at system32\config\systemprofile, which the restricted service-account token cannot write to - and Chromium touches those paths during startup independent of root_cache_path, which is why giving each launch a unique cache directory alone didn't fix the ProcessSingleton failure reported against a real install (confirmed by the directory being empty afterwards, and rebuilding/reinstalling/rebooting not changing the symptom). Fixed with CreateEnvironmentBlock(token) in ipc.rs::spawn_with_token, falling back to the caller's environment only if that call itself fails (possible on the account's very first launch, before its profile directory exists on disk). Also: log the cache path browser_state_dir creates, and leave a failed run's directory in place instead of wiping it, so a CefInitialize failure is inspectable afterwards rather than erasing its own evidence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…in window CefInitialize kept failing with Chromium's ProcessSingleton error even after a unique per-launch cache directory and a proper per-token environment block - neither was the cause. Comparing our token acquisition, desktop, and spawn API against Google Credential Provider for Windows's equivalent (chrome/credential_provider/gaiacp/) turned up no difference in any of those - because GCPW's sign-in UI helper is not a Chromium browser process at all. It launches rundll32.exe reloading its own DLL through an entrypoint, never touching chrome_main_delegate.cc or ProcessSingleton in the first place. CEF 151 supports both a Chrome style runtime (the full Chrome UI/browser layer - extensions, profile manager, ProcessSingleton) and a lighter Alloy style runtime meant for embedding, and windowed browsers default to Chrome style. Neither this credential provider's sign-in window nor its BrowserView need any Chrome UI, so both now return RuntimeStyle::ALLOY - a Chrome style Window can only host one Chrome style BrowserView, so both had to move together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
So a real-install log can be matched against the exact commit that produced the binary, rather than assumed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
S4U was replaced by LogonUserW+CreateRestrictedToken several commits ago; a few comments and e2e/README.md still described the old design (one outright wrong - e2e/README.md called the current logon an "S4U logon", which it isn't). Also trimmed several doc comments in ipc.rs/main.rs that had grown long through iterative edits, including one in window.rs that overclaimed the Alloy runtime style change fixes ProcessSingleton - later research showed CEF's Chrome bootstrap runs unconditionally regardless of window style, so that comment was actively misleading, not just long. BROWSER_PRIVILEGE.md's own S4U section is left alone - it's the historical record of why S4U was tried and abandoned, not leftover code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ProcMon showed ak_cef.exe getting ACCESS DENIED on every attempt to open C:\ProgramData\Authentik Security Inc\logs\ak_cef_chromium.log (and its fallback, System32\debug.log) - the logs folder was created by the installer with no permission grant for ak-wcp-browser, unlike wcp-cache. This means ak_cef_chromium.log has likely been frozen at whatever content an earlier, differently-privileged run left it at, for every attempt since the service-account redesign landed - the "still failing the exact same way" log excerpts examined over the last several rounds of debugging may never have reflected the current build's actual behavior at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… makes handle.exe confirmed no stale Local\ChromeProcessSingletonStartup! mutex exists between attempts, ruling out a wrong-DACL'd leftover object - but that only tells us nothing PERSISTS, not why a fresh CreateMutex might fail. Chromium's own diagnostic for this specific failure is DPLOG(FATAL), which compiles to nothing in a release build, and Process Monitor doesn't capture mutex operations at all. Ask the same question ourselves, through ak_platform::log (Windows Event Log), which is confirmed working, instead of continuing to reason about internals we can't directly observe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ntity Confirmed against a real install: CreateMutex(Local\ChromeProcessSingletonStartup!) fails with ERROR_ACCESS_DENIED on a fresh create (handle.exe found nothing pre-existing, ruling out a stale wrong-owner object). Three follow-up CreateMutex calls narrow down why: a random name under Local\ tells apart "this token can't create named mutexes in the session namespace at all" from "this specific, publicly-documented string is blocked" (endpoint security software is the leading suspect for the latter); a Global\ name checks whether it's specific to the per-session namespace; a Chrome-ish but not-exact name checks whether the block matches the literal string or the product name more loosely. Also logs the token's mandatory integrity level, session id, and whether Windows considers it IsTokenRestricted, to rule in or out the double access-check restricted tokens get (service_account_token only passes DISABLE_MAX_PRIVILEGE, no SIDs to disable/restrict, so this is expected to read false - confirming that removes one more hypothesis either way). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ntik/platform into ee/wcp/browser-privilege
Diagnostics confirmed the root cause: a LOGON32_LOGON_BATCH token cannot create any named synchronization object (mutex/event/semaphore) in the session namespace at all. CreateMutex(Local\ChromeProcessSingletonStartup!) failed with ERROR_ACCESS_DENIED, and so did CreateMutex with random UUID names under both Local\ and Global\ - ruling out a stale wrong-owner object (handle.exe found nothing) and a name-specific block (endpoint security software was the leading suspect until random names failed identically). The same token creates named pipes, files, and registry keys fine; only synchronization objects are affected. Batch logons carry the NT AUTHORITY\BATCH well-known SID rather than INTERACTIVE, and it's a documented Windows hardening pattern (CIS/STIG baselines) to scope BaseNamedObjects creation rights to interactive/ service identities specifically, excluding batch - plausible on a baselined Windows Server test box. Switches to LOGON32_LOGON_SERVICE (SeServiceLogonRight/LogonAsService, not SeBatchLogonRight/LogonAsBatchJob). Unverified whether this alone fixes it or whether BaseNamedObjects also needs an explicit ACL grant for the account, the same way ensure_desktop_access already does for WinSta0\Winlogon. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
LOGON32_LOGON_SERVICE alone was not enough: the service account's token still couldn't create named synchronization objects (CreateMutex etc.) in the session's BaseNamedObjects directory, which is fatal to Chromium's ProcessSingleton. GCPW hits the identical requirement for its own logon SID and answers it the same way (AllowLogonSIDOnLocalBasedNamedObjects), via NtOpenDirectoryObject since no Win32 wrapper exists for opening an arbitrary Object Manager directory.
… state The CreateMutex/token diagnostics in cef-host's main() served their purpose (root-causing the BaseNamedObjects failure) and don't belong in the shipped feature. Rewrites BROWSER_PRIVILEGE.md from a chronological debugging journal into a concise doc describing the final design, condensing the "roads not taken" investigation into short factual paragraphs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.