feat(federation): silent browser bootstrap via DeniedHttpResponse - #23
Open
nijave wants to merge 3 commits into
Open
feat(federation): silent browser bootstrap via DeniedHttpResponse#23nijave wants to merge 3 commits into
nijave wants to merge 3 commits into
Conversation
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.
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.
Summary
Sec-Fetch-Dest/Accept) now gets served a tiny bootstrap HTML page straight fromCheck()'sDeniedHttpResponse— no Envoy/Contour config changes beyond the ext_authz wiring already required for federation.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/authorizeroute 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'srequest.pathalways includes it), so the bootstrap page and the code redemption it triggers aren't caught in another authz cycle.get_bearerbearer-injection ladder still runs for those.envoy_authz/federator/browser_bootstrap.pyholds the navigation-detection and HTML-rendering helpers;callback_path()extracted intofederator/vikunja.pyand shared between the real callback flow and the new pass-through check.docs/federation.md) rewritten to describe the browser-bootstrap mechanism and drop the abandoned response-header/Lua-filter approach.Testing
tests/unit/test_browser_bootstrap.py(6 tests) covers navigation detection and HTML rendering/escaping.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.ruff check/ruff formatpass.Test plan
poetry run pytest— full suite passespoetry run ruff check ./ruff format --check— clean