# Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. # # binarybeachio fork addition — see BINARYBEACHIO.md at repo root. # # Per-app edge-identity validation. Detects when the platform edge identity # (oauth2-proxy's `_bb_oauth2` cookie, surfaced via `X-Auth-Request-User`) # has been swapped to a different Zitadel `sub` since this Plane session was # minted, and forces a re-OIDC by flushing the Django session. # # Without this, signing in as identity A then switching the edge to B (via # `bridge.binarybeach.io/logout` + signing in as B at Zitadel) leaves the # Plane `session-id` cookie pointing at A's User row. The SPA's same-origin # XHRs continue to authenticate as A even though the edge now carries B — # the staleness window is bounded only by Plane's `SESSION_COOKIE_AGE` # (default 7d, see settings/common.py). # # Per docs/conventions/per-app-edge-identity-validation.md (binarybeachio repo). import logging from django.contrib.auth.models import AnonymousUser log = logging.getLogger("plane.bb_edge_identity") _COOKIE_NAME = "_bb_edge_sub" # Django ALL_CAPS-ifies and prefixes headers with HTTP_; X-Auth-Request-User # arrives as request.META["HTTP_X_AUTH_REQUEST_USER"]. _HEADER_KEY = "HTTP_X_AUTH_REQUEST_USER" class BbEdgeIdentityMiddleware: """Insert AFTER `plane.authentication.middleware.session.SessionMiddleware` AND `django.contrib.auth.middleware.AuthenticationMiddleware` in MIDDLEWARE so `request.session` and `request.user` are populated when this runs. Decision tree per request: - `request.user` anonymous (no Plane session yet) -> pass through. Login, `/auth/sign-in-trusted/`, healthchecks (`/`), webhooks, and static assets all fall in this bucket; no explicit skip-path list needed. - No `X-Auth-Request-User` header (request didn't traverse oauth2-proxy: mobile bearer-token clients, internal Docker probes, dev) -> pass through. - Cookie missing -> lazy-populate via `Set-Cookie` on the response. Covers legacy sessions minted before this patch; one extra `Set-Cookie` then the next request is gated. - Cookie != header -> edge identity changed. Flush the Django session, replace `request.user` with `AnonymousUser` so downstream views/permissions see the request as unauthenticated, and clear `_bb_edge_sub` on the response. The request itself proceeds with anonymous identity (DRF's `IsAuthenticated` returns 401; browser navigations hit the front-door Traefik redirect to `bridge.binarybeach.io/handoff`). - Cookie == header -> pass through. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): edge_sub = request.META.get(_HEADER_KEY, "") cookie_sub = request.COOKIES.get(_COOKIE_NAME, "") write_cookie = "" clear_cookie = False # Only validate authenticated requests. AuthenticationMiddleware sets # request.user; before that runs (or if the chain ordering ever # regresses) the attribute may be absent. user = getattr(request, "user", None) if edge_sub and user is not None and not user.is_anonymous: if not cookie_sub: write_cookie = edge_sub elif cookie_sub != edge_sub: log.warning( "bb_edge_identity: edge identity changed; flushing session", extra={ "cookie_sub": cookie_sub, "edge_sub": edge_sub, "path": request.path, }, ) try: request.session.flush() except Exception as exc: log.error( "bb_edge_identity: session flush failed", extra={"err": str(exc)}, ) request.user = AnonymousUser() clear_cookie = True response = self.get_response(request) # If the inner view explicitly set `_bb_edge_sub` (the trusted-JWT # entry-point does this on session mint), don't override. Django's # SimpleCookie holds whatever was set last; checking presence avoids # stomping a fresh real value with our delete or stale lazy-populate. if _COOKIE_NAME not in response.cookies: if clear_cookie: response.delete_cookie(_COOKIE_NAME, path="/") elif write_cookie: response.set_cookie( _COOKIE_NAME, write_cookie, httponly=True, secure=True, samesite="Lax", path="/", ) return response