fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) - #3800
fix(android): sign in to servers behind front-door auth proxies (Databricks Apps)#3800btli wants to merge 72 commits into
Conversation
834d6f5 to
3c45c8c
Compare
|
Marking this ready for review. Two things it needs that I can't close from a fork, and one honest gap in verification. Still needs a live test on a managed deviceI cannot side-load this build into a work profile, so the front-door flow is verified only up to the point where the tenant login page renders inline in the WebView. The test deployment's IdP federates to Google and account policy blocks that leg here, so these remain unverified end to end:
Anyone with work-profile access can validate in a few minutes:
CI never runs the Android unit testsThis branch takes the Android suite from 21 to 221 tests, but no workflow runs I have a working job for this, verified green on a GitHub runner in 2m52s, but it can't land through this PR: test:
name: Unit tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: web/android
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Robolectric's SDK provider rejects JDK 17 for these compileSdk levels:
# every test class fails at DefaultSdkProvider. The app still targets
# Java 17 bytecode; only the JVM running Gradle needs to be newer.
- name: Set up JDK 21
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4
with:
cache-read-only: false
- name: Run unit tests
run: ./gradlew testDebugUnitTest --no-daemon --console=plainThe JDK 21 line is the part worth keeping: copying the sibling build job's JDK 17 looks right (the app targets Java 17 bytecode) and fails — under 17 every Robolectric class dies with One open design question for maintainersThe user-agent masking added by the earlier commits on this branch is removed here. It was measurably broken (the mask/restart sequence looped indefinitely), and it spends a third party's compliance risk: Google's "use secure browsers" policy attaches to the OAuth client, which under enterprise SSO belongs to the hosting platform or the customer rather than to us. The accepted cost is that any proxy/IdP combination which would have accepted a masked UA now needs the browser fallback instead. If you'd rather keep masking behind a flag, say so and I'll rework it. |
|
Note on the red checks here, so they aren't mistaken for this branch: Pre-commit (
One-line fix filed as #4012; these should go green once it lands and this branch is rebased or re-run. The Android-specific signals are green: |
621fd80 to
e2d9857
Compare
|
CI update after the archive fix landed on The one remaining red, |
|
Pushed 3 commits addressing the adversarial-review findings:
Verification: Android unit suite 240/240 on JDK 21; ktlint clean. Recommended merge order — this PR is one of seven fixed and verified together; the fully integrated reference (all cross-PR conflicts resolved, 5,003 web tests + 273/273 Android tests green) is Android track (suggest landing first — security fixes, and the later two resolve against it):
Web track (file-disjoint from the Android track; order within it matters): |
|
E2E coverage note for the Local-run proof: the PR ships an extensive Robolectric/JVM suite covering every changed path (origin canonicalization incl. UTS-46/IPv4/IPv6/port edges, cookie path scoping per endpoint and per redirect hop, fail-closed null-pin page starts, OIDC flow-slot race, download hardening). Requesting a maintainer apply the |
e37110f to
0b6d77a
Compare
On a tablet or unfolded foldable the right Workspace rail rendered under the status bar, leaving its tab icons un-tappable, and ran under the gesture-nav bar at the bottom. The rail is `md:m-2` and only renders at md+, but every native-shell inset rule lives inside `@media (width < 48rem)` — so none of them reach it — and it is absent from the shared panel selector lists on both the CSS and Android-injected sides. Add the safe-area margins outside the width gate, and mirror the same declaration into the Android injected sheet so shells pointed at an older web build get the fix too. Uses --omnigent-safe-*, not --omnigent-inset-*: the latter folds in the native bottom-bar footprint and would double-count. Signed-off-by: Bryan Li <bryan.li@gmail.com>
The panel docks as a full-height rail at md+ but was missing from both inset selector lists, so it had no safe-area padding at any width. It is reachable only in debug mode, which is why it went unnoticed. Pre-existing gap, adjacent to the Workspace rail fix rather than part of it. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A media-gated override appearing later in index.css with the same selector previously slipped past the ancestry check, which only inspected the first matching rule. Assert over every rule that sets the Workspace rail's margins so a later override at md+ fails the suite instead of silently winning the cascade. Signed-off-by: Bryan Li <bryan.li@gmail.com>
The safe-area assertion read only the first matching rule, so a second top-level rule could reintroduce the composite inset vars — double counting the native bottom bar — while the suite stayed green. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Only the longhand properties were matched, so a shorthand `margin` declaration set both edges without ever being checked for an enclosing at-rule. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Matching the exact selector text let an equivalently-spelled one through, and checking only the first rule let a later override zero the margins without naming a banned variable. Match on the rail's aria-label and require the margin rule to be unique. Signed-off-by: Bryan Li <bryan.li@gmail.com>
…eir origin Adversarial review found three shipping defects. A renderer crash left onRenderProcessGone returning false, which kills the app and leaves an unusable WebView; the client now reports it and MainActivity recreates. A login result carried no origin, so switching servers mid-poll injected the previous server's session JWT as the new server's cookie — results are now bound to the origin that started them and dropped when it no longer matches. Rejections and timeouts showed a transient toast with no way forward; they now show a generic retry dialog that restores the attempt budget, distinct from the browser-required refusal dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
setCookie acknowledges asynchronously, so a server switch landing between the write and its callback reloaded the previous server while pinnedOrigin already named the new one, leaving the WebView and the host disagreeing about which server is displayed. The continuation now lives behind onSessionCookieSet, which re-checks the origin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
A completed login delivered through a shared callback field, so a second login starting between completion and delivery received the first flow's result — the previous server's token installed as the new server's session. Each flow now owns its callback and a cancellation generation. A server switch also left the old login in flight, which made the new server's sign-in a no-op with nothing on screen; switching now cancels the abandoned flow so the new one can start immediately. The refusal and generic-failure dialogs no longer stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Login could stop responding with nothing on screen: an exhausted retry budget, a malformed session token and a rejected cookie all returned silently. Each now shows the generic failure dialog, whose Retry restores the budget. Renderer-crash recovery is capped at two recreations (reset once a pinned page loads) so a renderer that dies on every load reports instead of spinning. The poll deadline moves to a monotonic clock, non-http links still hand off while the refusal dialog is up, and the refusal copy now says that signing in via the browser does not sign you in inside the app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
The cancel test only covered a flow interrupted mid-poll, so the suppression half stayed green even with the generation check removed. This drives the real interleaving: the flow completes and posts its result, cancel lands before the looper drains it, and the abandoned callback must not run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
The browser-visibility <queries> block put both http and https <data> schemes in a single <intent>, which the package parser rejects — the APK failed to install with INSTALL_PARSE_FAILED_MANIFEST_MALFORMED. Robolectric does not parse the manifest this way, so only a device install surfaces it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ly once it lands Past the recreation cap the shell showed "Couldn't sign in" for a renderer crash and offered a Retry that ran a login against a defunct WebView; a renderer failure now has its own dialog whose action rebuilds the shell, and it is no longer suppressed by a showing refusal dialog. The "Signed in" notification and the reorder-to-front moved behind the cookie acknowledgement, so a rejected cookie no longer reads as success followed by failure. A same-package test subclass finally drives the real onReceivedError override. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
… them A notification's PendingIntent carried only the navigate path, and a server switch cancelled nothing and left pendingNavigatePath set. Tapping server A's stale notification after switching to B therefore routed A's conversation id into B's SPA, and the single badge slot showed A's count while pinned to B. Stamp the posting origin into every activation intent and drop an activation whose origin isn't the current pin; cancel outstanding notifications and clear the pending activation on switch. The launch intent's extras are now consumed when read, so a recreate() can't replay an activation the SPA already handled, and notification ids come from persisted state so a new Activity or process can't reuse an unread notification's id. Also fix a crash on the blob path: onDestroy shut the saver's executor down before removing the bridge, so a download message already queued on the UI handler hit a dead executor and threw RejectedExecutionException out of a Handler callback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
The login manager was an Activity field and onDestroy shut it down, so any recreation while the user was away in the system browser killed the poll. The likeliest trigger is a renderer crash of the backgrounded WebView -- exactly what Android reclaims when the user leaves -- which routes to recreate(). The user finished signing in and nothing happened: no cookie, no notification, no error, and returning popped the browser a second time. Scope the manager to the process and have each Activity attach an origin-bound callback, so a result completing while nothing is attached is held and replayed to the next Activity. Detaching clears the callback, which is the only thing that could retain the Activity. Leaving for good still abandons the flow: onDestroy cancels when isFinishing, so an exit mid-login can't hold the eventual timeout and replay it as a sign-in failure on the next launch. The two paired tests pin that condition from both sides -- dropping the guard leaves a finished exit polling, and making it unconditional breaks the recreation case the fix exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
…sion Attaching the WebView cookie to a DownloadManager.Request was not safe. Android's DownloadProvider follows redirects and re-applies custom request headers on every new connection, including after the host changes, so a same-origin /export that redirects to object storage handed __Host-ap_session to that third party. Redirecting downloads to a CDN is a common deployment, so the initial-origin check bought nothing. Download pinned-origin URLs ourselves instead: follow redirects manually with instanceFollowRedirects off, re-evaluating each hop, and drop the cookie permanently the moment one leaves the pinned origin -- including when a later hop returns to it. The body streams to the same MediaStore destination the blob path uses, so a large artifact is never held in memory. Cross-origin downloads still go to DownloadManager with no cookie, exactly as before. The file chooser gate remains main-frame-only: FileChooserParams exposes no requesting-frame origin, so a cross-origin iframe on a pinned page still passes where the microphone path would not. Documented at the gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Five follow-ups, all cases where a fix stopped short of the boundary it was meant to cover. The webViewUnusable branches of onNewIntent and reloadWithNewServer recreated without running the parts of the switch teardown that don't need a WebView, and a recreation isn't finishing so onDestroy didn't cancel either. Switching servers from a dead-renderer state therefore left the old server's login in flight -- so the new server's start() returned false and sign-in silently no-opped for up to five minutes -- and left its notifications in the shade. destroyUnusableWebView left the login attachment in place, so a result arriving while the renderer was dead counted as delivered and was then dropped by the guards. Detach instead, so it is held for the Activity the user gets after Retry. Consuming the launch intent's extras stopped a handled activation replaying, but pendingNavigatePath lived only in a field, so a recreation before the page was ready lost the tap entirely. Persist it across the save/restore cycle. A held result now survives only if it is a Success: a failure with nobody attached has no one waiting for it, and holding it meant a five-minute timeout from an abandoned attempt could surface as a sign-in failure dialog at the next launch, hours later. The notification id wrap branch was fully shadowed by the normalization on the next read, which made its test green with the branch removed. Drop the branch and pin the rule that actually exists. Finally, a bridge message dispatched to the UI looper just before a server switch was stamped with the new pin, because removeWebMessageListener stops future messages but not one already queued. Reject a message whose sourceOrigin isn't the current pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Cancelling the previous server's login, withdrawing its notifications and dropping its pending activation all ran only if a MainActivity instance was alive to run them. Three separate paths defeat that. With "Don't keep activities", opening the connect screen destroys MainActivity non-finishing, so picking a new server strands it behind the old server's flow -- start() returns false and sign-in silently no-ops. setIntent() assigns Activity.mIntent but ActivityThread relaunches from ActivityClientRecord.intent, so the dead-renderer branch dropped the notification tap it had just been handed. And removeExtra only mutates the client-side Intent, so after process death the system's copy re-supplies an activation the SPA already handled. Move the invariants to chokepoints that don't depend on an Activity: attaching to an origin now abandons a flow belonging to a different one, the last pinned origin is persisted so a mismatch withdraws stale notifications at startup, and the pending activation is persisted with its origin and dropped when it doesn't match the pin. Saved state is authoritative when present, so a handled tap is not replayed from the relaunch intent. Storage safety: the pre-Q path wrote straight to the final name, so a failed stream truncated a previously downloaded file and then reported failure. It now writes to a temporary file and moves it into place only on success, and saves of the same name are serialized across instances. Download failures are logged rather than collapsing into one silent toast, and the API 28 message no longer claims the file is in Downloads when it is in app-private storage. Test adequacy, all three verified holes: the cookie predicate ANDs scheme, host and port but the fixture differed only in port, so deleting the host comparison left the whole suite green; no test exercised a successful MediaStore save, the path every real device takes; and safeFileName was pinned by nothing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Downloading pinned-origin URLs ourselves stopped the session cookie leaking across redirects, but it moved the transfer onto an in-process executor, so a large download died whenever Android reclaimed the cached process. DownloadManager had survived that. Run the transfer as a WorkManager job instead, so the OS reschedules it after process death. The redirect and cookie-scoping logic is unchanged -- it is now the body the worker executes. The session cookie is deliberately NOT worker input: WorkManager serializes input Data into its Room database, so passing it would write a credential to disk. The worker reads the live cookie from CookieManager when it starts, and a test asserts the persisted WorkSpec row contains no cookie. Transient failures (IO, timeouts, 5xx) retry with bounded backoff; terminal ones (4xx, hop cap, missing or non-http Location, a rejected origin) fail without retrying. A retry re-downloads from the start -- there is no resumption. Because a worker cannot rely on being foreground, completion is surfaced as a notification on its own channel, tagged so it can never be confused with an origin-bound session activation or disturb the persisted session id counter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Review of the WorkManager conversion found five defects, two of them serious. Nothing in the module honored isStopped. Worker.stop() sets a flag and calls onStopped(); it does not interrupt doWork(). So a transfer outliving the execution window kept streaming as a zombie -- still writing, still announcing completion for work the framework had abandoned -- and because a stop is not a Result.retry(), runAttemptCount never advanced, so the attempt bound gave no protection. WorkManager's executor is a small fixed pool, so a few zombies would starve every job in the process. The transfer now checks isStopped between hops and inside the copy loop, and runs as a long-running foreground worker so the ten-minute cap no longer applies. Reading the cookie when the job starts keeps it off disk, but it also means a deferred run can find the session expired. The front door then redirects to its IdP and the loop dutifully saved the login page as report.pdf and announced success -- the exact bug this branch exists to fix, reappearing in the deferred case. Origins.isProxyAuthUrl already classifies that bounce, so treat it as terminal and tell the user to sign in again. Also: workDataOf throws above 10KB and the exception escaped through the DownloadListener, so a long signed URL crashed the app; 408 and 429 were terminal, ignoring exactly the backoff a throttled server asks for; and process death left a duplicate published row on Q+ and an orphaned temporary on API 28. The worker's only surface was a notification, so with notifications denied the outcome was invisible on success and failure alike -- the path it replaced always toasted. There is a fallback again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
A WorkManager stop reschedules without touching runAttemptCount, so a transfer that keeps outliving its execution window restarted from byte zero forever with nothing shown to the user. Persist a per-work stop counter (incremented in onStopped, cleared on terminal outcomes) and fail honestly after three stops; a failed foreground promotion is only tolerated on the work's first life, since a later window is known to be too short for the file that already outlived one. Also close the round of smaller gaps around the worker: fall back to the toast path when just the downloads channel is blocked, reject a terminal HTML body when the caller asked for a non-HTML file, include pending rows in the MediaStore sweep and journal probe (they are filtered from queries by default), prune the journal once a row is published, and replace the bucketed save monitors with per-name refcounted locks acquired with an abort-aware timed loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ash reruns A stop that voids an already-published run's result made the rerun download a duplicate, because the journal entry was pruned at publish time. Entries now outlive publication — a rerun recognizes its row — and growth is bounded by a 64-entry sequence-ordered cap instead of eager pruning; evicted pending rows become sweepable orphans. Every run start increments runAttemptCount (retries, stops, and crash recoveries alike — verified against the shipped WorkManager bytecode), so a MAX_LIVES entry gate now bounds the one restart path the stop counter cannot see: repeated process deaths mid-transfer. Comments that claimed stops leave runAttemptCount untouched are corrected. The outcome fallback's check-then-commit and register-then-drain pairs now share one lock, so a result delivered while the activity is starting can no longer be stranded invisibly in preferences. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Eviction picked victims purely by age, so one slow transfer plus 64 younger journaled saves evicted the in-flight entry — and the next save's sweep then deleted the still-pending row under the writer, failing the download. In-process operations now register themselves while writing and eviction skips them; crash orphans stay evictable. Also pin the seq-sibling cleanup in eviction, which no test observed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
…viction shield Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
…nges The pre-commit wrapper exits 0 when ktlint is absent, so these files were never actually formatted locally; CI, which installs ktlint, rejected them. No behavior change — 221 tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Session cookie is now HttpOnly (matching the server's own attribute), permission/file-chooser/download/bridge gates deny when no origin is pinned instead of comparing null == null, originOf canonicalizes IDN hosts to punycode, DownloadManager enqueue failures surface as failed downloads instead of crashing, and worker notifications use the sanitized filename. Origin switches ignore callbacks from the previous navigation until the new origin's first page starts. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
readInput() discarded the result of the suggested-name lookup purely for its early return, then aliased the sanitized name through a local. State the requirement as an explicit null check and pass the sanitized name directly. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Android's Uri leaves a non-numeric port inside host instead of failing the parse, so "https://good.com:notaport" came back as its own origin. WHATWG treats an invalid port as a parse failure — return null, keeping bracketed IPv6 literals. First actual run of this suite (Robolectric was network-blocked when the tests were written) caught it; the other 239 tests pass. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
java.net.IDN is IDNA2003 and maps hosts like faß.de to fass.de — a different registrable domain than the xn--fa-hia.de the WebView actually loads and reports in navigation callbacks, so the pinned origin could never match its own pages. Canonicalize with android.icu's UTS-46 (non-transitional, CHECK_BIDI/CHECK_CONTEXTJ) instead, and pass bracketed IPv6 literals through unmapped, with regression coverage pinning that they already canonicalized correctly. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
The worker cleared the active-flow slot on the io thread before posting the result to the main thread, so a login trigger queued in that window could start a second concurrent browser flow and both tokens would deliver. The slot now stays occupied until the result crosses to the main thread (where it is vacated just before delivery, so a retry from the callback can start a fresh flow). Also remove the tests-only start(...) overload with a flow-local callback override that bypassed deliverOrHold; the tests now exercise the production attach/deliver path. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
- Gate HTML responses even when no MIME type was requested (common from onDownloadStart): a proxy's 200 HTML login page must not be saved as the requested file. Intent falls back to the file-name extension so genuine .html downloads still save. - Bring the CookieManager fetch inside the worker's error handling: a WebView-provider-unavailable exception now fails like any transient error (notification + stop-count cleanup) instead of crashing past both. - Compare the worker-side origin gate through originOf so the enqueue side and worker side canonicalize hosts identically. - Share one safeFileName and SHA-256-hex helper across DownloadStorage and the downloader, and derive the work name and failure id from a single downloadIdentity function. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Address round-2 review findings on the proxy-auth/download path: - Query WebView cookies for the full /auth/* endpoint URL (login manager) and per redirect hop (download worker) so Path-scoped proxy session cookies match; the off-origin cookie ratchet is unchanged. - Only let onReceivedHttpError end an in-flight proxy-auth flow for hops that never committed — an IdP's 401/403 interactive login page must not drop the flow while the user is typing. - Match the embedded-sign-in rejection on error/error_subtype parameter values (including one encoding level deep) instead of a URL-wide substring, so state=disallowed_useragent no longer aborts login. - Delete the pending MediaStore row when a download is cancelled right after row creation; cancelled work is never retried. - Canonicalize IP-literal hosts in originOf (RFC 5952 IPv6 compression, WHATWG IPv4 shorthand) so pins like [0:0:0:0:0:0:0:1] or 127.1 match the WebView's canonical host, and reject servers at connect time whose host originOf cannot canonicalize instead of pinning a null origin. - DRY: reuse clearCrossServerState in reloadWithNewServer, share the downloader TAG, fold canDeliver into canDeliverLocked, and drop a redundant null check in the download gate. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Only one trailing empty dot label is ignored (1.2.3.. stays a Chromium domain instead of collapsing onto the 1.2.0.3 pin), and an all-digit last label forces the IPv4 parse so hosts like foo.09 are rejected at connect time the way Chromium rejects them. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
WHATWG IPv4 numbers carry no sign, so +1 stays a domain instead of pinning as 0.0.0.1; ports beyond 65535 are rejected at canonicalization so an unloadable server can't be persisted; and an SSL error only ends a proxy-auth flow when it hit the flow's own tracked main-frame hop — a subresource cert failure no longer misroutes the next redirect into native login. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
A port too large for Int overflows Uri.parsePort to -1, silently dropping it instead of rejecting the URL; and an over-long numeric label is a valid WHATWG number that must fail the 32-bit address range, not fall through to the domain path. Both persisted a server Chromium would refuse to load. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
8069d91 to
a3ba067
Compare
Related issue
Closes #3799
Summary
The Android shell cannot sign in to a server deployed behind a hosting platform's front-door auth proxy (Databricks Apps; architecturally also Cloudflare Access, EasyAuth, oauth2-proxy). Two user-visible symptoms, one cause: switching to such a server appears to do nothing — the previous server's page stays painted — and sign-in never completes, with no error shown.
The edge 302s every path to its IdP, including
POST /auth/cli-login. The shell misread that bounce as omnigent's own OIDC redirect: it cancelled the navigation (leaving the stale page rendered) and started the native browser login, whose ticket request was itself 302'd into IdP HTML and failed silently.This is not a one-line fix, because the front door binds the flow to the cookie jar that starts it — its first 302 sets an
HttpOnly; SameSite=NoneCSRF cookie — and Android has no browser→WebView cookie import. The flow must therefore complete inline in the WebView, which means the shell has to tell a front-door bounce apart from its own OIDC bounce and stay in that flow without turning into a general-purpose browser.What this PR does
Origins.isProxyAuthUrl): an off-origin URL whoseredirect_urireturns to the pinned origin on a path other than omnigent's own/auth/callback. omnigent's own bounce keeps the RFC 8252 system-browser path unchanged.IDLE/IN_FLIGHT/REFUSED) and a five-row dispatch table, so a proxy-shaped URL can never fall through to the branch that cancels the navigation — the structural bug behind symptom 1.OidcLoginManager: a malformed poll payload was retried for the full five-minute deadline, andstart()aftershutdown()threw on the main thread while leaving the in-flight flag stuck true. Login now reports aLoginResult(Success/Rejected/TimedOut), and failures surface a "Couldn't sign in to<host>" dialog with Retry instead of only a logcat line.Defects found by adversarial review during this work, each with a regression test: a login result was not bound to the server that started it, so switching servers mid-poll installed the previous server's session token as the new server's cookie; a completed flow could deliver its result to a different flow's callback; a server switch during a login stranded the new server with no surface and no retry; a renderer crash killed the app because
onRenderProcessGonereturnedfalseand nothing rebuilt the WebView; and a latesetCookieacknowledgement could reload the previous server after a switch.Second wave: multi-engine bug scan of the whole Android shell (commits
ac7b8c24..3c45c8cb). A five-engine adversarial scan (codexgpt-5.6-solxhigh + parallel Claude reviewers) of the app produced 2 HIGH / 13 MEDIUM findings; the HIGHs and every cross-server MEDIUM are fixed here, then the fixes themselves went through seven review rounds until both engines attested with no findings:pendingNavigatePathand login results got the same treatment: browser logins live in a process-scopedOidcLoginManagerwith explicitattach/detach, so a login finished in the browser lands afterrecreate(), but a result from a previous server can never install its session on the new one.recreate(). The file chooser refuses to open unless the main frame is still on the pinned origin.DownloadManagerre-applies custom headers after a cross-host redirect (AOSPDownloadThread), leaking the session cookie to wherever a redirect chain ends. Downloads now run in an own pinned-origin downloader under WorkManager: manual redirects with a monotonic cookie-drop rule (once a hop leaves the pinned origin the cookie never comes back), front-door auth bounces and terminal HTML-as-PDF responses rejected as "sign in again", 408/429/Retry-Afterhonored, long-runningdataSyncforeground execution with stop handling, a persisted three-stop bound plus an eight-lives entry gate that also bounds process-death restart loops (verified against the shipped WorkManager 2.11.2 bytecode: every run start incrementsrunAttemptCount), and MediaStore idempotency across process death via a capped, eviction-safe operation journal. Outcomes stay visible when notifications or just the downloads channel are blocked (toast fallback with a persisted replay).Data.Accepted residuals, documented in code: no
Rangeresumption (retries restart from byte zero, now bounded); the file-chooser gate is main-frame-only (FileChooserParamsexposes no requesting-frame origin); Play will require adataSyncFGS declaration and Android 15+ steers user-initiated transfers toward UIDT.Test Plan
cd web/android && ./gradlew testDebugUnitTest— 221 tests, 0 failures (up from 21 onmain, 121 after the first wave). NeedsANDROID_HOMEand Android Studio's JDK 21.0, the same value the flow-start field defaults to, so deleting the start-time stamp left the suite green while a real device (uptime is never0) would have expired every flow the instant it entered.pre-commit run --all-files— both Android ktlint hooks pass. (web prettier/web oxlintfail identically on unmodifiedmainand cover onlyweb/**TypeScript, which this branch does not touch.)proxy-auth nav … loading inline, thenproxy-auth landing … loading inline). Againstomnigent.joyful.house:cli-login -> ticket ok,opening login in browser, and Chrome comes to the foreground at the IdP — the system-browser path is unchanged.<queries>block declared two<data>schemes inside one<intent>, which Android's package parser rejects outright (INSTALL_PARSE_FAILED_MANIFEST_MALFORMED) — the APK would not install.Not verified: completing a sign-in end-to-end. The test deployment's IdP federates to Google and account policy blocks that leg here, so validation stops at the login page rendering in the right place. The
disallowed_useragentrefusal detector is likewise unverified against a live refusal; it is documented as such, and the menu escape hatch means a missed detection never strands the user.gpt-5.6-solxhigh + independent Claude reviewers, re-reviewing after every fix). Rounds 1–6 each found real defects — including an unbounded stop→restart loop, a session-cookie path to third parties, an executor-starvation mode, and a duplicate-download race — all fixed and mutation-pinned. Round 7: both engines attest with zero findings on the final SHA.Demo
Emulator, this branch's APK, against the live deployments.
Launching against the front-door server — the Databricks tenant login page renders inline in the WebView, with the server pill still showing the pinned host. The shell classified the edge's 302 as a front-door bounce rather than its own OIDC redirect (
proxy-auth nav … loading inline, thenproxy-auth landing … loading inline) and stayed in the flow instead of cancelling the navigation:cli-loginsucceeds and the system browser opens at the IdP (RFC 8252).The permanent escape hatch in the server switcher, available regardless of flow state:
Connect-time origin gate (review-round hardening)
Recorded on the emulator against a local
omnigent server.normalizeServerUrlnow requires the pinned-origin canonicalizer to accept the URL, so inputs the
gate can't canonicalize fail at connect time with a visible error instead of
persisting a server whose null pin makes the fail-closed page gate stop every
load (a mute blank screen). Shown: a port past the 16-bit range
(
example.com:99999999999, which also overflowsUri.parsePortto -1), then amalformed port (
good.com:notaport), then a successful connect that loads theweb UI with the pinned host on the server pill.
Type of change
Test coverage
Coverage notes
Manual verification covered both routing outcomes on an Android emulator — the live Databricks deployment and a non-proxied server — as described in the Test Plan. Sign-in could not be completed end-to-end because the IdP's Google leg is blocked by account policy here, so the post-login session path and the refusal detector remain manually unverified; both are covered by unit tests against synthetic inputs.