Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,24 @@ class CefWebController {
Future<void> navigate(String url) =>
_channel.invokeMethod('navigate', {'sessionId': sessionId, 'url': url});

/// Open [url] in a windowed Chrome-runtime browser for a WebAuthn / passkey
/// sign-in ceremony (Touch ID, account picker, hybrid QR).
///
/// The OSR tile itself cannot host WebAuthn — `navigator.credentials.create/get`
/// hangs because there is no window for the system sheet to attach to. This pops
/// a real Chrome-runtime window that CAN, sharing this tile's cookie jar so the
/// sign-in propagates back to the tile once the window is closed. Typically
/// called with the tile's current address ([url]'s live value).
Future<void> openAuthWindow(String url) {
// Defense-in-depth (mirrored authoritatively in the Swift session): only ever
// open the cookie-bearing auth window at a real web origin. Refuse non-http(s)
// schemes (javascript:/data:/file:/about: ...) rather than round-tripping them.
final scheme = Uri.tryParse(url)?.scheme.toLowerCase();
if (scheme != 'https' && scheme != 'http') return Future<void>.value();
return _channel
.invokeMethod('openAuthWindow', {'sessionId': sessionId, 'url': url});
}

/// CEF-2a — enable agent control for this tile and return a brokered, token-gated
/// CDP endpoint a standard CDP client (e.g. `agent-browser`) can connect to.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class CefProfileHost {
// processGone) instead of silently mis-parsing frames into frozen/blank tiles; the
// skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale
// embedded copies (the content-hash fetch can't drift on the normal path).
static let protocolVersion: UInt8 = 3
static let protocolVersion: UInt8 = 4

// Profile identity / config.
let profileId: String
Expand Down
17 changes: 17 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ final class CefWebSession: NSObject, FlutterTexture {
private static let opShowDevTools: UInt8 = 0x33
private static let opLoadTrusted: UInt8 = 0x34
private static let opSetVisible: UInt8 = 0x35
private static let opOpenAuthWindow: UInt8 = 0x39

// Event callbacks (fired off the main thread). The registrar relays each to a
// Dart channel message.
Expand Down Expand Up @@ -348,6 +349,22 @@ final class CefWebSession: NSObject, FlutterTexture {
sendFrame(Self.opNavigate, Array(url.utf8))
}

/// Open a windowed Chrome-runtime browser at |url| for a WebAuthn / Touch ID
/// ceremony the OSR tile cannot host. Shares this session's cookie jar.
///
/// Defense-in-depth: this pops a cookie-bearing, user-navigable top-level window
/// outside OSR containment, so only ever launch it at a real web origin. Reject
/// anything that isn't http(s) (javascript:/data:/file:/about: ...) -- the tile's
/// own `navigate` is scheme-gated in cef_host; keep this entry point in step.
func openAuthWindow(_ url: String) {
guard let scheme = URL(string: url)?.scheme?.lowercased(),
scheme == "https" || scheme == "http" else {
NSLog("[flutter_cef] openAuthWindow refused non-http(s) URL")
return
}
sendFrame(Self.opOpenAuthWindow, Array(url.utf8))
}

/// A host content-injection load (loadHtmlString -> data:, loadFile -> file:):
/// exempt from the navigation scheme allowlist, unlike `navigate`.
func loadTrusted(_ url: String) {
Expand Down
11 changes: 11 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
switch call.method {
case "create": create(args, result)
case "navigate": navigate(args, result)
case "openAuthWindow": openAuthWindow(args, result)
case "loadTrusted": loadTrusted(args, result)
case "resize": resize(args, result)
case "getFrameSurface": getFrameSurface(args, result)
Expand Down Expand Up @@ -621,6 +622,16 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
result(nil)
}

/// Open a windowed Chrome-runtime browser at |url| for a WebAuthn / Touch ID
/// ceremony the OSR tile cannot host. Shares the session's cookie jar so the
/// sign-in propagates back to the tile.
private func openAuthWindow(_ a: [String: Any], _ result: @escaping FlutterResult) {
if let id = a["sessionId"] as? String, let url = a["url"] as? String {
sessions[id]?.openAuthWindow(url)
}
result(nil)
}

/// Host content-injection load (loadHtmlString/loadFile) — bypasses the
/// navigation scheme allowlist in cef_host.
private func loadTrusted(_ a: [String: Any], _ result: @escaping FlutterResult) {
Expand Down
120 changes: 120 additions & 0 deletions packages/flutter_cef_macos/native/build-cef-from-source.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Build a PATCHED CEF binary distribution from source (macOS arm64), carrying the
# Campus WebAuthn keychain-access-group patch so cef_host can use the on-device
# platform authenticator (Touch ID / Secure Enclave) for passkeys.
#
# WHY: OSR/Alloy-style windowless browsers can't host WebAuthn UI, so the sign-in
# ceremony runs in a windowed Chrome-runtime browser. On-device Touch ID there is
# gated by the keychain-access-group Chromium names
# "<team>.org.chromium.Chromium.webauthn" -- but UNBRANDED CEF leaves the team
# prefix EMPTY (".org.chromium.Chromium.webauthn"), which no valid entitlement can
# match. The prebuilt CEF exposes no API/switch for it, so the only fix is a
# from-source rebuild with native/patches/campus_webauthn_keychain.patch.
# Full write-up: work_canvas specs/cef-passkey/PLAN.md.
#
# This is Tier-2 (build libcef from source), NOT a Chromium fork -- fold it into
# the same from-source build you stand up for proprietary codecs.
#
# Usage: DOWNLOAD_DIR=/Volumes/CEFBuild/cef_src/chromium_git ./build-cef-from-source.sh
# Output: $DOWNLOAD_DIR/chromium/src/cef/binary_distrib/cef_binary_<ver>_macosarm64_minimal
# -> point build_cef_host.sh at it via FLUTTER_CEF_CACHE (see bottom).
#
# Requirements (learned the hard way):
# - FULL Xcode (not just CLT). Xcode 26 split out the Metal toolchain: run
# xcodebuild -downloadComponent MetalToolchain
# once, or ANGLE shader compilation fails ("missing Metal Toolchain").
# - ~150 GB free on a CASE-SENSITIVE volume (Chromium checkout can hit case
# collisions on case-insensitive APFS). 32 GB+ RAM.
# - Network access to chromium.googlesource.com (first checkout ~70 GB).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"

# Pinned to match the prebuilt cef_host wrapper's CEF API (keep in lockstep with
# native/build_cef_host.sh CEF_VERSION).
CEF_VERSION="144.0.27+g3fae261+chromium-144.0.7559.254"
CEF_COMMIT="3fae261" # exact CEF commit (= g3fae261 in the version)
CEF_BRANCH="7559" # Chromium branch base position (= 144.0.7559.x)
CEF_NAME="cef_binary_${CEF_VERSION}_macosarm64_minimal"

DOWNLOAD_DIR="${DOWNLOAD_DIR:?set DOWNLOAD_DIR to a case-sensitive path with ~150GB free}"
SRC="$DOWNLOAD_DIR/chromium/src"
AUTOMATE="$DOWNLOAD_DIR/../automate/automate-git.py"
PATCH="$HERE/patches/campus_webauthn_keychain.patch"

[ -f "$PATCH" ] || { echo "missing patch: $PATCH" >&2; exit 1; }

# 0. Bootstrap automate-git.py if absent.
if [ ! -f "$AUTOMATE" ]; then
echo "[cef-src] fetching automate-git.py"
mkdir -p "$(dirname "$AUTOMATE")"
curl -fsSL \
"https://raw.githubusercontent.com/chromiumembedded/cef/master/tools/automate/automate-git.py" \
-o "$AUTOMATE"
fi

# 1. Checkout Chromium + CEF at the exact pinned revs, apply CEF's stock patches,
# but DO NOT build yet (so we can inject our patch first).
echo "[cef-src] checkout (Chromium ${CEF_VERSION}) -- first run downloads ~70GB"
python3 "$AUTOMATE" \
--download-dir="$DOWNLOAD_DIR" \
--branch="$CEF_BRANCH" --checkout="$CEF_COMMIT" \
--arm64-build --no-build --no-distrib

# 2. Apply the Campus keychain patch to the Chromium tree (idempotent).
# Applied directly via `git apply` -- it is NOT registered in CEF's patch.cfg,
# so a stock/unpatched build is unaffected purely via the content-hash + variant
# marker machinery (tool/cef_host_hash.sh + CEF_FRAMEWORK_VARIANT), not via any
# patch-cycle condition.
if git -C "$SRC" apply --check --reverse "$PATCH" 2>/dev/null; then
echo "[cef-src] keychain patch already applied"
else
echo "[cef-src] applying keychain patch"
git -C "$SRC" apply "$PATCH"
fi

# 3. Build RELEASE arm64 with is_official_build=true. This flag is load-bearing
# THREE ways, do NOT downgrade it to a bare dcheck_always_on=false build:
# (a) it matches exactly how the stock Spotify prebuilt framework is built;
# (b) it strips DCHECKs -- a plain from-source build enables them and CRASHES
# on startup at chrome_paths_mac.mm (Chrome's framework-layout DCHECK is
# incompatible with CEF's Versions/A layout);
# (c) it FIXES Google-account caBLE/QR passkey sign-in. A non-official build
# breaks it ("Something went wrong" after the email step, post-passkey);
# the official build completes the flow (reaches Google's passkey
# enrollment page). NB: the -67030 process_requirement log line is a
# BENIGN red herring -- present in the working build too; don't chase it.
# proprietary_codecs + ffmpeg_branding="Chrome" add H.264/AAC. These are
# ROYALTY-BEARING (MPEG-LA / Via-LA) -- gate *distribution* (prod release-tag)
# on legal sign-off. See work_canvas specs/cef-passkey/PLAN.md.
echo "[cef-src] build (Release arm64, official + H.264/AAC) -- multi-hour first compile"
GN_DEFINES='is_official_build=true proprietary_codecs=true ffmpeg_branding="Chrome"' \
python3 "$AUTOMATE" \
--download-dir="$DOWNLOAD_DIR" \
--branch="$CEF_BRANCH" --checkout="$CEF_COMMIT" \
--arm64-build --no-debug-build \
--no-update --force-build --no-distrib

# 4. Package the minimal distrib. --no-symbols is REQUIRED: --no-debug-build means
# no .dSYM, and the default make_distrib dies packaging symbols. --no-format
# avoids a clang-format dependency.
echo "[cef-src] make_distrib (minimal, no symbols)"
python3 "$SRC/cef/tools/make_distrib.py" \
--output-dir="$SRC/cef/binary_distrib" \
--ninja-build --arm64-build --minimal --no-symbols --no-format --no-docs

DIST="$SRC/cef/binary_distrib/$CEF_NAME"
echo "[cef-src] DONE -> $DIST"
echo
echo "Verify the patch made it into the framework:"
echo " strings -a '$DIST/Release/Chromium Embedded Framework.framework/Chromium Embedded Framework' | grep KLAJ5X6PJP.org.chromium.Chromium.webauthn"
echo
echo "Build cef_host against it (drop-in for the Spotify prebuilt):"
echo " mkdir -p /tmp/cef_cache && ln -sfn '$DIST' /tmp/cef_cache/$CEF_NAME"
echo " FLUTTER_CEF_CACHE=/tmp/cef_cache CEF_HOST_ADHOC=OFF \\"
echo " CODESIGN_ID='Developer ID Application: FlutterFlow, Inc. (KLAJ5X6PJP)' \\"
echo " '$HERE/build_cef_host.sh'"
echo
echo "cef_host then needs (see specs/cef-passkey/PLAN.md): the keychain-access-group"
echo "entitlement on the BROWSER process only + an embedded Developer-ID"
echo "provisioning profile authorizing that group. Release Campus.app is already"
echo "signed+notarized, so the validation-category condition is satisfied there."
32 changes: 32 additions & 0 deletions packages/flutter_cef_macos/native/build_cef_host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ if [ ! -f "$CEF_ROOT/cmake/FindCEF.cmake" ]; then
tar -xjf "$tarball" -C "$CACHE"
fi

# Campus framework-variant guard. If the pinned variant (native/cef_host/
# CEF_FRAMEWORK_VARIANT) is not 'stock', the CEF framework at CEF_ROOT MUST be the
# PATCHED from-source build (provided by pointing FLUTTER_CEF_CACHE at the output
# of native/build-cef-from-source.sh). Fail loudly rather than silently linking
# the stock Spotify framework under a patched content hash -- the exact silent
# drift cef_host_hash.sh's variant fold-in guards against.
CEF_VARIANT="stock"
[ -f "$HERE/cef_host/CEF_FRAMEWORK_VARIANT" ] && \
CEF_VARIANT="$(tr -d '[:space:]' < "$HERE/cef_host/CEF_FRAMEWORK_VARIANT")"
[ -z "$CEF_VARIANT" ] && CEF_VARIANT="stock"
if [ "$CEF_VARIANT" != "stock" ]; then
echo "[flutter_cef] CEF framework variant: $CEF_VARIANT (expecting a PATCHED from-source framework)"
case "$CEF_VARIANT" in
*webauthn*)
_fw="$(find "$CEF_ROOT/Release" -name 'Chromium Embedded Framework' -type f 2>/dev/null | head -1)"
# grep -c (not -q): -q exits early and SIGPIPEs strings, which under
# `set -o pipefail` makes the pipeline fail even on a MATCH (false negative).
_fw_hits=0
[ -n "$_fw" ] && _fw_hits="$(LC_ALL=C strings -a "$_fw" 2>/dev/null \
| grep -c 'KLAJ5X6PJP\.org\.chromium\.Chromium\.webauthn')"
if [ -z "$_fw" ] || [ "${_fw_hits:-0}" -eq 0 ]; then
echo "[flutter_cef] FATAL: variant '$CEF_VARIANT' expects the WebAuthn keychain patch, but the" >&2
echo " framework at $CEF_ROOT does not contain it." >&2
echo " Build the patched framework with native/build-cef-from-source.sh and point" >&2
echo " FLUTTER_CEF_CACHE at its binary_distrib output before running this script." >&2
exit 1
fi
echo "[flutter_cef] verified: WebAuthn keychain patch present in framework"
;;
esac
fi

echo "[flutter_cef] building cef_host.app ..."
MP_FLAG="-DCEF_MULTI_PROCESS=ON" # default: renders heavy SPAs + crash-isolated
if [ "${CEF_MULTI_PROCESS:-}" = "0" ] || [ "${CEF_MULTI_PROCESS:-}" = "OFF" ]; then
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
campus-webauthn-h264
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- AUTH SPIKE ONLY (not shippable): entitlements for the TOP-LEVEL cef_host
(browser) process. Applied by hand at re-sign time, not by the build.
Superset of entitlements.release.plist + the passkey keychain group + the
identity entitlements that let a Developer-ID provisioning profile bless
that group. Helpers keep the minimal entitlements.release.plist. -->
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.device.bluetooth</key><true/>
<!-- On-device passkeys (Touch ID / Secure Enclave). Chromium's macOS FIDO
backend refuses Touch ID unless the browser process is entitled for
TEAMID.org.chromium.Chromium.webauthn. KLAJ5X6PJP is our Team ID. -->
<key>keychain-access-groups</key>
<array>
<string>KLAJ5X6PJP.org.chromium.Chromium.webauthn</string>
</array>
<!-- Identity entitlements. These must match the embedded provisioning profile
(reuse path = Campus_Dev_Direct_Distribution.provisionprofile, whose
application-identifier is KLAJ5X6PJP.io.flutterflow.campus.dev.mac and whose
keychain-access-groups wildcard KLAJ5X6PJP.* covers the group above).
application-identifier also supplies the runtime keychain PREFIX Chromium
reads (its absence was the "empty prefix" failure in the first spike). -->
<key>com.apple.application-identifier</key>
<string>KLAJ5X6PJP.io.flutterflow.campus.dev.mac</string>
<key>com.apple.developer.team-identifier</key>
<string>KLAJ5X6PJP</string>
</dict>
</plist>
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@
<!-- Bluetooth is load-bearing: caBLE / hybrid passkeys (WebAuthn cross-device)
reach Web Bluetooth, so this stays even in the minimal release set. -->
<key>com.apple.security.device.bluetooth</key><true/>
<!-- NOTE (auth spike): the on-device-passkey keychain-access-group lives in the
SEPARATE entitlements.browser.plist and is applied ONLY to the top-level
cef_host (browser) process at re-sign time. Helpers (renderer/GPU/utility)
do NOT do keychain work, so keeping the group out of this shared file means
they need no provisioning profile and won't be AMFI-killed. -->
</dict>
</plist>
47 changes: 46 additions & 1 deletion packages/flutter_cef_macos/native/cef_host/main.mm
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
// stale embedded copy). BUMP THIS on any semantic change to the kOp wire protocol
// below, together with CefProfileHost.protocolVersion (Swift side) — the two must
// stay equal. Hosts predating the handshake send a 1-byte payload and read as v0.
constexpr uint8_t kCefHostProtocolVersion = 3;
constexpr uint8_t kCefHostProtocolVersion = 4;

// ---- Opcodes ----
constexpr uint8_t kOpPresent = 0x01;
Expand Down Expand Up @@ -162,6 +162,7 @@
constexpr uint8_t kOpResolveTargetId = 0x36; // {} resolve this browser's CDP targetId (CEF-2b) -> kOpTargetId
constexpr uint8_t kOpInvalidate = 0x37; // {} C1: force a repaint (Invalidate PET_VIEW) to re-kick a stalled first frame
constexpr uint8_t kOpEditCommand = 0x38; // {u8 cmd} run a focused-frame edit command (0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo)
constexpr uint8_t kOpOpenAuthWindow = 0x39; // {utf8 url} open a windowed Chrome-runtime browser for a WebAuthn/Touch ID ceremony the OSR tile can't host (shares the tile's cookie jar)

// ---- Shared runtime state ----
// Atomic: the reader thread reads it (ReadAll), SendFrame on any thread reads it,
Expand Down Expand Up @@ -1184,6 +1185,29 @@ void OnTitleChange(CefRefPtr<CefBrowser>, const CefString& title) override {
IMPLEMENT_REFCOUNTING(PopupClient);
};

// SPIKE (passkeys): a bare client for a windowed, CHROME-runtime browser. The
// Chrome runtime manages its own native window, toolbar, and — crucially — the
// full WebAuthn stack (Touch ID sheet, account picker, hybrid QR), none of which
// exist in the Alloy/OSR runtime the tiles are forced into. So this needs no
// handlers; it exists to prove a Campus-spawned window can complete a passkey
// ceremony the OSR tile cannot.
class AuthWindowClient : public CefClient {
IMPLEMENT_REFCOUNTING(AuthWindowClient);
};

// SPIKE: open a real windowed Chrome-runtime browser at |url|, sharing THIS
// process's cookie jar (global request context == the tile's named profile, since
// profiles are per-cef_host-process via root_cache_path). Windowed + Chrome style
// = the WebAuthn UI can actually draw. Env-triggered one-shot from OnAfterCreated.
void OpenChromeAuthWindow(const std::string& url) {
CefWindowInfo wi; // default → windowed, CEF owns the NSWindow
wi.runtime_style = CEF_RUNTIME_STYLE_CHROME; // full Chrome UI + WebAuthn stack
wi.bounds = CefRect(120, 100, 520, 760); // a plausible sign-in window
CefBrowserSettings settings;
CefBrowserHost::CreateBrowser(wi, new AuthWindowClient(), url, settings,
nullptr, nullptr); // nullptr ctx = shared cookies
}

static void OpenNativeAuthPopup(const CefString& url, const CefPopupFeatures& f,
CefWindowInfo& window_info,
CefRefPtr<CefClient>& client) {
Expand Down Expand Up @@ -1423,6 +1447,16 @@ void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
slot_->begin_frame_pump_started = true;
PumpBeginFrame(slot_->browser_id);
}
// SPIKE (passkeys): one-shot — open a windowed Chrome-runtime auth window on
// the first tile, so we can test whether a passkey ceremony completes there
// (it hangs in the OSR/Alloy tile). Off unless FLUTTER_CEF_SPIKE_AUTH_URL set.
static std::atomic<bool> s_auth_opened{false};
if (const char* au = std::getenv("FLUTTER_CEF_SPIKE_AUTH_URL")) {
bool expected = false;
if (s_auth_opened.compare_exchange_strong(expected, true)) {
OpenChromeAuthWindow(au);
}
}
}

// CefLifeSpanHandler: route popups (window.open / target=_blank) to the host
Expand Down Expand Up @@ -2409,6 +2443,17 @@ void IpcReadLoop() {
base::BindOnce(&DoNavigateByWireId, wire_id, url, true));
break;
}
case kOpOpenAuthWindow: {
// Open a windowed Chrome-runtime browser at |url| for a WebAuthn / Touch
// ID ceremony. The OSR/Alloy tile CANNOT host WebAuthn (no window for the
// Touch ID sheet); the Chrome runtime can. nullptr request context = this
// process's global cookie jar == the tile's named profile (profiles are
// per-cef_host-process via root_cache_path), so a sign-in propagates back
// to the tile. Process-level: no slot required.
std::string url(reinterpret_cast<const char*>(p), plen);
CefPostTask(TID_UI, base::BindOnce(&OpenChromeAuthWindow, url));
break;
}
case kOpReload:
if (!slot) break;
CefPostTask(TID_UI, base::BindOnce(&DoReload, slot));
Expand Down
Loading
Loading