Skip to content

feat(android): add Auth Tab login for self-managed front doors - #4536

Open
btli wants to merge 32 commits into
omnigent-ai:mainfrom
btli:polly/android-authtab
Open

feat(android): add Auth Tab login for self-managed front doors#4536
btli wants to merge 32 commits into
omnigent-ai:mainfrom
btli:polly/android-authtab

Conversation

@btli

@btli btli commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Related issue

Partially addresses #3799 for operator-managed, same-origin front doors. It does not close #3799: direct *.databricksapps.com origins cannot expose Digital Asset Links anonymously and continue using the #4296 inline WebView fallback.

Supersedes #3800's retired WebView-interception approach.

Summary

This PR adds Android Auth Tab login for deployments that can publish a verified Android app association on the exact server origin. Round 6 fixes two Android regressions found after the server-side flow was approved:

  • Fallback routing: Auth Tab capability no longer changes an origin's established fallback. Direct Databricks Apps origins fall back inline; every other origin retains the RFC 8252 system-browser flow, including capable origins with no Auth-Tab-capable provider or a dismissed/failed tab.
  • Exact capability probe: the anonymous DAL probe now requires handle_all_urls, namespace android_app, the running context.packageName, and the SHA-256 fingerprint of the running APK's signing certificate. A non-empty but unrelated/wrong-keystore assetlinks.json no longer launches Auth Tab.
  • Operational hardening: Reload/server switch invalidates cached probe results, stale callbacks clear pending state safely, Databricks/no-provider paths skip the network probe, and native exchange codes are removed before validation and restored only after non-fatal mismatches.
  • Completable recipe: docs now require header auth mode plus a proxy-forwarded per-user token, a debug build/debug-keystore fingerprint for log verification, and the exact DAL predicate used by the app.

ELI5

The app asks, “Does this server explicitly name this exact installed APK, and is there a browser that supports Auth Tab?” If yes, it may try Auth Tab. If that attempt cannot run or fails, the app goes back to the login method that already worked for that server instead of forcing login into a WebView.

Off-origin login bounce
        |
        +-- direct Databricks Apps ----------------> inline WebView (#4296)
        |
        +-- matching DAL + Auth Tab provider ------> Auth Tab
        |                                              |
        |                                              +-- success -> credential exchange
        |                                              +-- failure -> origin's established fallback
        |
        +-- everything else -----------------------> RFC 8252 system browser

Supported deployment reality

Deployment Exchange Result
Self-managed same-origin front door + header mode + anonymous matching DAL + forwarded per-user token tab Supported; this is the #3799 scenario addressed by this PR.
Directly reachable oidc / accounts origin + matching DAL post Supported as an Auth Tab UX option; RFC 8252 already worked.
Front door + oidc / accounts server post Cannot complete because the native POST has no front-door browser session.
Direct *.databricksapps.com origin N/A Auth Tab does not fire; the #4296 inline fallback remains.

Human verification recipe

  1. Run Omnigent with OMNIGENT_AUTH_PROVIDER=header. Configure the front door to forward a per-user token in X-Forwarded-Access-Token, or set OMNIGENT_FORWARDED_TOKEN_HEADER to the actual protected header name.
  2. Route https://omnigent.example.com to Omnigent and exempt only GET/HEAD /.well-known/assetlinks.json from front-door authentication.
  3. Build a debug APK with ./gradlew assembleDebug; use the debug variant SHA-256 from ./gradlew signingReport in OMNIGENT_ANDROID_AUTH_TAB_APPS with OMNIGENT_NATIVE_AUTH_BASE_URL.
  4. From a client with no cookies or Authorization, verify the file contains the exact relation, namespace, package, and fingerprint documented in web/android/README.md.
  5. Install that debug APK on a device with an Auth-Tab-capable browser, enter the same origin, clear the front-door browser session, and start login.
  6. Confirm adb logcat -s OmnigentAuth reports asset links probe ... -> available and proxy login -> auth tab; complete login and verify the SPA loads authenticated in the WebView.
  7. Publish [], remove the anonymous exemption, or use a non-matching fingerprint; choose Reload, retry, and verify the established fallback runs without launching Auth Tab.
  • HUMAN verification required: the device/front-door steps above were not performed in this environment.

Residuals

  • Header-mode tab exchange necessarily places the one-time code + PKCE verifier in the second-hop URL and the relayed bearer token in the final callback URL; browser/front-door diagnostics can expose them.
  • Pending flows remain process memory; multi-worker/multi-replica custom deployments need sticky routing or a shared store.
  • Digital Asset Links enforcement remains browser-authoritative; the app-side probe is only a capability hint.
  • Front-door behavior for the final Bearer navigation remains a live-device verification item.

Test Plan

  • cd web/android && JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home" ./gradlew testDebugUnitTest --rerun-tasks
    • JDK 21.0.12.
    • XML totals: 105 tests, 0 failures, 0 errors, 0 skipped across 12 suites.
  • uv run --frozen python -m pytest tests/server/test_native_auth_complete.py -q
    • 43 passed, 0 failed, with 1 dependency deprecation warning.
  • git diff --exit-code uv.lock
    • Passed; uv.lock is unchanged.
  • uv run --frozen --extra dev pre-commit run --files <11 changed files>
    • All applicable hooks passed in a clean validation clone outside .worktrees, including Pyrefly, ruff, web Prettier, Android ktlint format/check, and file-hygiene hooks. The repository's .worktrees ignore causes Pyrefly to see no configured sources when run in-place.

Demo

N/A from this environment. The user-visible Auth Tab flow requires the unchecked physical-device/self-managed-front-door verification above.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Android tests cover exact DAL matching, malformed/unrelated/wrong-keystore associations, cache invalidation, probe coalescing, direct-Databricks probe avoidance, capable-origin Auth Tab selection, and the required capable-origin/no-provider RFC 8252 fallback. Server tests cover both exchange transports, single-use redemption, retry-safe validation failures, forwarded-token behavior, callback-origin binding, and configuration validation. Live browser/front-door/device behavior remains the explicit unchecked item above.

Changelog

Android can sign in through Auth Tab on verified self-managed front doors while preserving the existing inline or system-browser fallback for every other deployment.

@github-actions github-actions Bot added P1-high Priority: major feature broken, no workaround waiting-for-review size/XL Pull request size: XL labels Aug 10, 2026
@github-actions
github-actions Bot requested a review from fanzeyi August 10, 2026 19:34
@github-actions

Copy link
Copy Markdown
Contributor

@btli This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Aug 10, 2026
@btli btli changed the title feat(android): sign in to front-door-auth (Databricks-hosted) servers via the Auth Tab feat(android): add Auth Tab login for self-managed front doors Aug 10, 2026
@btli

btli commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Cross-vendor review complete — 7 rounds

This PR was implemented and reviewed by independent agents of different vendors (implementer never reviewed its own diff; reviewers received only the diff plus an acceptance contract, never the worktree). Final verdict: APPROVE, ready to merge after a real-device check.

Blockers found and closed, by round

Round Blocker Resolution
1 Unbound /auth/native-complete oracle; bearer material in redirect URL PKCE S256 + single-use 120s initiation record; one-time code + verifier exchange
2 Custom-scheme squatting; VIEW trampoline accepted unsolicited callbacks VIEW trampoline deleted entirely; nothing claims the scheme
3 Third-party app could run the whole protocol and redeem the code https:// callback verified via Digital Asset Links; transport bound per flow
4 Callback built from request.url_forhttp:// behind a TLS front door (feature silently inert); no <queries> element → provider null on Android 11+; exchange-identity check permanently 401'd exchange=post Configured HTTPS-only base URL; <queries> declared; identity scoped to tab, POST bound by code+state+PKCE verifier
5 Auth Tab path was dead code on every supported deployment (hardcoded domain trigger vs. DAL requirement) Capability probe: anonymous assetlinks.json fetch, non-empty match, cached per origin
6 Capability destroyed the RFC 8252 system-browser path (login went from working to impossible on devices without an Auth-Tab-capable browser); probe predicate accepted any unrelated assetlinks.json Three-way routing (Auth Tab / inline / system browser); strict predicate incl. client-side signing-cert fingerprint match
7 Nits only Fingerprint normalization + rotated-key lineage; padding tolerance; merge onto current main

Verification

All gates were re-run independently by the orchestrator at each round, not taken from worker reports. At the final head: Android 133 tests / 14 suites / 0 failures / 0 errors / 0 skipped (JDK 21.0.12), native-auth 43 passed, full tests/server 3,594 passed / 1 skipped / 3 xfailed, uv.lock unchanged.

The merge of current main was audited specifically because #4543 (workspace root mount) and #4555 (nav-chrome hiding) touch the same off-origin region this PR restructures. Result: routing intact at both entry points, both upstream behaviors still reached, no ordering hazard. The resolution did drop one upstream @Test annotation (a login chain landing on the workspace root bounces to the mount, from #4543) — a silently disabled test — which was caught and restored in 5f229a47b. Every @Test across all 14 merged Android test files was swept against its upstream counterpart: no other annotation was lost, no test function from either parent is missing.

Scope — please read before merging

Auth Tab activates only on:

  • header mode behind a self-managed same-origin front door that serves /.well-known/assetlinks.json anonymously and forwards a per-user access token, and
  • directly-reachable oidc/accounts deployments with both env vars set.

It does not activate on a direct *.databricksapps.com origin (the platform cannot serve the DAL file anonymously) or on a front door with an oidc/accounts server. Those keep the #4296 inline fallback. The PR title and body were corrected accordingly and the Closes #3799 claim removed — #3799 is addressed only for operator-managed front doors.

Outstanding

  • HUMAN: real-device verification against a live self-managed front door — recipe at web/android/README.md. Requires a debug build (authLog is BuildConfig.DEBUG-gated).
  • Follow-up nits (non-blocking): rotated-key lineage is proven by inspection but not by test (needs a ShadowPackageManager test); the fallback test asserts absence-of-reload rather than positively asserting startLogin(); one redundant forget() call.
  • Documented residuals: DAL remains a browser-side control the server cannot attest; tab transport exposes code/verifier and the final bearer to browser/proxy diagnostics; pending flows are process-local (safe under every supported single-worker topology, fails closed otherwise).

btli and others added 21 commits August 11, 2026 06:53
Replace the retired WebView-interception approach for Databricks-hosted
(front-door auth proxy) servers with the official androidx.browser Auth
Tab surface: when an in-WebView-auth server bounces off-origin to its
front door / IdP, the shell opens the server's new
GET /auth/native-complete in an Auth Tab, the whole redirect chain runs
in a real browser context (IdPs that refuse embedded user-agents, e.g.
Google federation, work there), and the server completes back into the
app with a fixed omnigent://auth-callback redirect carrying the app's
state nonce plus a credential: the proxy-forwarded
X-Forwarded-Access-Token in header mode, a minted session JWT in
oidc/accounts mode. The app binds the result to the flow that launched
it (state nonce + pinned origin) and bootstraps the WebView — Bearer on
the main-frame load, or the session cookie via the existing inject path.

Devices/browsers without Auth Tab support, dismissed tabs, and servers
without the endpoint all fall back to the existing inline in-WebView
flow. The credential-bearing redirect is only ever expected through the
Auth Tab's Activity-result channel; the omnigent://auth-callback
manifest filter (scoped to that host only) exists for the degraded
Custom-Tab path, where an unmatched state nonce drops the intent.

Also fixes onConfigurationChanged reading the stale cached configuration
for system-bar icon polarity.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

Co-authored-by: omnigent <noreply@omnigent.ai>
…allback

Address cross-review findings on the Auth Tab login:

- The completion redirect no longer carries a credential. An
  authenticated /auth/native-complete hit now creates a short-lived
  (120s), single-use flow record binding the app's state nonce and PKCE
  S256 challenge to the identity, and redirects only an opaque one-time
  code. The credential is released by /auth/native-exchange against
  code + state + code_verifier, with the record popped before any check
  so a failed guess burns the code; without a valid challenge no flow
  (and no code) is created at all, so an attacker-driven authenticated
  browser visit yields nothing exchangeable. There is deliberately no
  pre-registration endpoint: a front-door proxy 302s every
  unauthenticated native request, and a public registrar would not
  authenticate the initiator anyway — PKCE carries the binding.
- The exchange has two transports, chosen by the server: a native POST
  (oidc/accounts — the credential never touches a URL) and a second,
  silently authenticated Auth Tab hop for header-mode servers behind a
  front door, the only transport such a proxy leaves open.
- Removed the omnigent://auth-callback VIEW trampoline and manifest
  filter: callbacks are accepted only through the Auth Tab's
  Activity-result channel, so there is no squattable delivery surface
  and no VIEW path left to wedge a flow. On the launcher path an
  unmatched or malformed callback now always cancels the pending flow
  and takes the one-shot inline fallback (regression-tested), instead of
  leaving it armed and blocking every later login.
- The inline downgrade is now sticky for the process (reset only on a
  server switch) rather than re-armed by any pinned-origin page load.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

Co-authored-by: omnigent <noreply@omnigent.ai>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
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>
margin-block and its start/end forms set the same edges as the rule
being guarded, so an override written that way was never matched.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
The rule scan keyed on one literal spelling, so the same selector written
with single quotes, no quotes, or extra spacing introduced a margin the
uniqueness and safe-area checks never saw.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
The scan matched any spelling of the rail's attribute selector, but the
ancestry check then re-found one exact literal and skipped the rule when
it differed — two sources of truth for the same question, and a rule
whose nesting was never actually checked.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
…tors

The ancestry walk built named at-rule records that no assertion ever
read — every use compared them to an empty list. Brace depth answers the
same question in a third of the lines and is stricter: it also rejects
the rule being nested inside a plain rule, which the ancestor form let
through.

Also stops `scroll-margin-top` and friends registering as margin
overrides.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
The rail now pads content clear of the OS bars on all four edges
instead of shrinking with top/bottom margins, so its background and
divider stay flush. Lateral insets are folded into --omnigent-safe-left
and -right and the published insets include the display cutout, which
systemBars() alone omits. The injected fallback sheet mirrors the same
rules.

Co-authored-by: Isaac

Signed-off-by: Bryan Li <bryan.li@gmail.com>
The Workspace-rail and full-height-panel checks each spelled out the same
four padding expectations and the same stylesheet append/remove dance, and
three call sites repeated the selector-extraction slice. They now go
through withStyle, expectSafeAreaPadding, and selectorOf. Also records why
the injected Android inset CSS must avoid backticks and `${`.

Co-authored-by: Isaac

Signed-off-by: Bryan Li <bryan.li@gmail.com>
btli added 11 commits August 11, 2026 06:55
The injected fallback sheet flattened index.css's media split, so at md+
widths panels docked inside the already-padded Workspace rail (frameless
FileViewer, right-docked drawers) were safe-area padded twice. Mirror the
(width < 48rem) guard around the :is(...) panel rule and pin it with a
structural test.

Also decouple the CSS layout test from WorkspacePanel's prop surface: a
stub aside carries the selector contract, a source assertion pins the
component's aria-label, and brace-depth scanning shrinks to a blanked-
source count.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A local uv invocation rewrote the lock's package URLs to an internal
proxy; restore the normalized PyPI form the lock check expects.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The rail only exists at md+, outside the mobile drawer rules, so its
safe-area padding has its own rule; this pins the injected-bridge ->
data-android-native -> inset-fold -> computed-padding chain end to end
with distinct per-edge insets.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Execution-logs / file-viewer / terminals panels replace the Workspace
rail in the same right-edge slot at md+, so they slid under the status
bar and cutout the rail was just fixed to avoid. Pad their top, bottom
and right edges (content sits to the left), exempting panels nested
inside the already-padded rail, in both index.css and the injected
fallback sheet. Share the CSS block-extraction across the test file.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The injected sheet is interpolated into a JS template literal, so a
backtick or dollar-brace in the CSS breaks the script at runtime while
the style-tag tests keep passing.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
The chat header's right edge clears the inline workspace rail via
--workspace-panel-offset. The +16 covered the rail's old outer margins;
the rail is flush now, so the offset is the rail width itself.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…bars

Scoping the injected panel inset rule to phone widths also stripped the
conversations sidebar's insets at md+, where it is a pinned column at the
left screen edge — unfolded, its header rendered under the status bar.
Pad its exposed edges (top/bottom/left) at md+ in index.css and mirror
the rule in the injected fallback sheet. Android-only: iOS md+ keeps its
current layout.

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
A server switch mid-login (server-switcher menu today, deep links next)
could inject the old server's session token into the new origin's
cookie store. Bind each login result to its originating origin and
cancel any in-flight login when the pinned server changes.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
@btli
btli force-pushed the polly/android-authtab branch from f3c47bd to 08f440b Compare August 11, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-demo PR needs a demo screenshot or recording P1-high Priority: major feature broken, no workaround size/XL Pull request size: XL waiting-for-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android shell cannot sign in to servers behind a front-door auth proxy (Databricks Apps): server switch appears dead, login silently fails

2 participants