Skip to content

feat(federation): silent browser bootstrap via DeniedHttpResponse - #23

Open
nijave wants to merge 3 commits into
mainfrom
feat/authz-bootstrap-token-response-header
Open

feat(federation): silent browser bootstrap via DeniedHttpResponse#23
nijave wants to merge 3 commits into
mainfrom
feat/authz-bootstrap-token-response-header

Conversation

@nijave

@nijave nijave commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • A browser navigating directly to a federated host (document navigation, detected via Sec-Fetch-Dest/Accept) now gets served a tiny bootstrap HTML page straight from Check()'s DeniedHttpResponse — no Envoy/Contour config changes beyond the ext_authz wiring already required for federation.
  • The bootstrap page seeds localStorage['state'] and navigates to Vikunja's own OIDC callback route with a freshly minted authorization code, exactly mimicking what clicking Vikunja's native "Login" button would produce. Vikunja's frontend then redeems the code itself via its existing callback flow — no bypass of Vikunja's own auth code, no OP /oauth/authorize route needed.
  • Check()'s federation branch now explicitly passes through Vikunja's own frontend OIDC route and backend callback route untouched (matched on path with the query string stripped, since Envoy's request.path always includes it), so the bootstrap page and the code redemption it triggers aren't caught in another authz cycle.
  • Non-navigation requests (XHR/fetch from an already-loaded SPA) are unaffected — the existing get_bearer bearer-injection ladder still runs for those.
  • New envoy_authz/federator/browser_bootstrap.py holds the navigation-detection and HTML-rendering helpers; callback_path() extracted into federator/vikunja.py and shared between the real callback flow and the new pass-through check.
  • Docs (docs/federation.md) rewritten to describe the browser-bootstrap mechanism and drop the abandoned response-header/Lua-filter approach.

Testing

  • New tests/unit/test_browser_bootstrap.py (6 tests) covers navigation detection and HTML rendering/escaping.
  • New/updated tests in tests/unit/test_check.py: bootstrap page served on document navigation, denial when the cert has no email, and pass-through for both Vikunja's frontend and backend OIDC paths — deliberately exercised with a query string attached, since a bare-path test would have passed even with the query-string matching bug this PR also fixes.
  • Full unit suite + ruff check/ruff format pass.
  • Manually verified end-to-end against a real Envoy v1.39 + Vikunja 0.24 Docker Compose stack fronted by mTLS, using a real homelab CA and client certificate, via curl simulating a browser's document navigation followed by the frontend's own code-redemption POST: the flow returns a genuine Vikunja-issued JWT with no manual login step. A live Firefox/Chrome check is a follow-up before calling desktop verification fully done.

Test plan

  • poetry run pytest — full suite passes
  • poetry run ruff check . / ruff format --check — clean
  • curl-simulated end-to-end flow against a live Envoy+Vikunja stack
  • Real browser (Firefox/Chrome) confirmation of the silent-login experience

nijave added 3 commits August 1, 2026 13:07
Check() already injects `Authorization: Bearer <token>` on the request
forwarded upstream, but that header is invisible to a browser SPA — it
never reaches the client, only Vikunja's backend. A cert holder hitting
Vikunja's web UI therefore still sees Vikunja's own client-side OIDC
login flow, which this federator's OP was deliberately never built to
handle (no /oauth/authorize route: the design mints codes server-to-
server only, per the header comment in op/routes.py).

Add `X-Authz-Bootstrap-Token` to `response_headers_to_add` whenever a
fresh bearer is minted, so a downstream response filter (e.g. an Envoy
Lua filter injecting a `localStorage` seed script into the HTML
document) can hand the same token to the browser and let the SPA boot
already authenticated, with no visible redirect. Omitted on the
"client's own bearer already valid" branch, since nothing new was
minted there.

Verified end-to-end against a real Envoy + Vikunja stack with a real
homelab mTLS client cert (manual local simulation, not part of this
change): Firefox loaded the page fully authenticated with no login
screen or redirect.
…a-header approach

Supersedes the X-Authz-Bootstrap-Token response header from the prior
commit: nothing ever consumed it (the Envoy Lua filter it was meant
for was local-only scaffolding, never committed), so it was dead
plumbing for an abandoned design. Reverted in favor of a mechanism that
needs zero Envoy/Contour changes.

A browser hitting a federated host directly still fell through to
Vikunja's own client-side OIDC login (the Authorization header
get_bearer injects is only ever visible to Vikunja's backend, never to
the browser's JS) — which redirects to this OP's authorization_endpoint,
a route that has never existed here by design (op/routes.py: codes are
minted server-to-server only).

Check() now detects a real browser navigation (Sec-Fetch-Dest: document,
falling back to Accept: text/html) to a federated host and denies it
with an HTTP 200 whose body does exactly what Vikunja's "Login with
<provider>" button would: stores a `state` in localStorage, then
navigates to Vikunja's callback with a code minted via the same
create_authorization_code() federate() already uses.
DeniedHttpResponse's status/headers/body are fully caller-controlled,
so this replicates Vikunja's real front-channel flow (its OpenIdAuth.vue
hard-fails on a state mismatch, so a bare server-initiated redirect
that skipped setting it first would not work) with Envoy never proxying
the hit to Vikunja at all.

Two Vikunja-owned paths are exempted from both this and the existing
ladder, or the exchange loops/breaks: the frontend callback route the
bootstrap page navigates to, and the backend endpoint that redeems the
code (now a shared vikunja.callback_path() helper instead of being
inlined only in federate()).

New envoy_authz/federator/browser_bootstrap.py holds the pure,
independently-tested helpers (navigation detection, path derivation,
HTML rendering with explicit escaping). Manually re-verified end to end
against a real Envoy + Vikunja stack with a real homelab mTLS client
cert: Firefox loads Vikunja fully authenticated with no visible
redirect, login screen, or Envoy config changes.
Envoy's request.path always includes the query string, so the OIDC
callback route (hit as .../broker?code=...&state=...) never matched
the bare-path comparison and fell through to another bootstrap cycle.
Found via live curl verification against a real Envoy + Vikunja stack.
@nijave nijave changed the title feat(federation): expose the minted bearer on the response path too feat(federation): silent browser bootstrap via DeniedHttpResponse Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant