Skip to content

feat(auth): add GET /auth/portal-sign-out/ for cross-app logout chain#34

Closed
awais786 wants to merge 3 commits into
foss-mainfrom
feat/portal-sign-out-endpoint
Closed

feat(auth): add GET /auth/portal-sign-out/ for cross-app logout chain#34
awais786 wants to merge 3 commits into
foss-mainfrom
feat/portal-sign-out-endpoint

Conversation

@awais786

@awais786 awais786 commented May 18, 2026

Copy link
Copy Markdown

Summary

Adds a GET method to the existing SignOutAuthEndpoint so the foss-server-bundle portal's "Log out of all apps" redirect chain can include Plane while reusing Plane's existing logout primitive (django.contrib.auth.logout()).

Single endpoint, two methods, single source of logout truth. No new view, no duplicated logic.

What changed

File What
`signout.py` Added GET method; refactored shared body into `_flush_session()` (records `last_logout_time`/`_ip`, calls `logout()`). POST behaviour unchanged.
`urls.py` Updated comment on existing `sign-out/` path. No new route.
`settings/common.py` Reads `PLATFORM_DOMAIN` env (set by `foss-server-bundle/platform.sh`) for `?next=` allowlist.
`test_signout.py` +6 cases covering the GET path.
`.env.example` Document the new `PLATFORM_DOMAIN` env.

Behaviour

`GET /auth/sign-out/?next=<absolute_url>`:

  • Calls the same `_flush_session()` the POST path uses → `logout(request)`.
  • Validates `?next=` host: MUST equal `PLATFORM_DOMAIN` or be a subdomain. Suffix match enforces dot boundary (`foss.arbisoft.com.evil` rejected).
  • `?next=` omitted → fall back to `MPASS_SIGNOUT_URL` (or root).
  • CSRF-exempt (class-level decorator); residual force-logout risk documented in docstring.

Why GET + CSRF-exempt

The portal builds a chained URL (`docs. → pm. → design. → ...`) and navigates the browser through each app. Cross-origin POSTs without CORS handshake fail; cross-origin CSRF tokens aren't shareable. The only chain-friendly shape is GET. Residual force-logout (``) loses only the session — auto re-auth via ForwardAuth — accepted as low impact.

Diff size

+212 / -11 across 5 files, of which:

  • `+87` in `signout.py` is the GET method + shared helper. Of that, ~50 lines are docstrings + ?next= validation; ~30 are the GET handler body.
  • `+122` in tests covers all the GET-side branches.

Tests

`apps/api/plane/tests/unit/views/test_signout.py` — 6 new GET cases (existing 4 POST cases unchanged):

  • redirect to allowlisted `?next=` (session still flushed)
  • reject `?next=` on disallowed host (400, session still flushed)
  • dot-boundary enforcement (`foss.arbisoft.com.evil` → 400)
  • empty `PLATFORM_DOMAIN` rejects every `?next=`
  • `?next=` omitted → fallback to `MPASS_SIGNOUT_URL`
  • malformed `?next=` rejected

Config

Setting Source Purpose
`PLATFORM_DOMAIN` env, set by `foss-server-bundle/platform.sh` `?next=` allowlist
`MPASS_SIGNOUT_URL` env (optional, existing) Fallback when `?next=` omitted

Out of scope

  • Portal-side "Log out of all apps" button that builds the chained URL — separate PR in foss-server-bundle.
  • Analogous endpoints on Outline / Penpot / Twenty / SurfSense — separate PRs (SurfSense in progress on `feat/portal-logout-endpoint` branch).
  • Spec change in sso-rules-moneta amending `logout-flow/spec.md` to require eager invalidation — separate PR.

History

Previously opened as #31, closed. Reopened as #34 with a new view + duplicated logic. Refactored per review feedback to fold into the existing endpoint — net diff dropped from ~440 lines to +212 / -11.

Comment thread apps/api/plane/authentication/views/app/portal_signout.py Fixed
awais786 and others added 2 commits May 18, 2026 17:46
The foss-server-bundle portal's "Log out of all apps" button needs to
clear each app's session cookie in addition to the shared _oauth2_proxy
cookie and the Cognito SSO session. Cross-origin Set-Cookie isn't a
thing — only the app whose origin owns the cookie can clear it — so
the portal has to navigate the browser through each app's domain.

This endpoint is the Plane participant in that chain:

  GET /auth/portal-sign-out/?next=<absolute_url>

  1. logout(request) — flushes the Django session
  2. Validates next against MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS (suffix
     match on a dot boundary; ".foss.arbisoft.com" matches subdomains
     but not "foss.arbisoft.com.evil")
  3. 302 to next

CSRF-exempt because the portal cannot obtain Plane's CSRF token cross-
origin. The residual risk is force-logout (an attacker embeds
<img src="…/portal-sign-out"> and ends the victim's session). That's
low-impact: the only state lost is the session itself, and re-auth via
ForwardAuth is automatic on the next request.

When ?next= is omitted or invalid, falls back to MPASS_SIGNOUT_URL
(if set) so a direct hit to the endpoint still chains through
oauth2-proxy + Cognito.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…STS to PLATFORM_DOMAIN

Reuses the bundle-wide PLATFORM_DOMAIN env var (set by
foss-server-bundle/platform.sh) instead of inventing a per-app
allowlist env. Same suffix-match semantics with dot-boundary
enforcement; less surface area for operator misconfiguration.

PLATFORM_DOMAIN unset → endpoint still flushes the Django session,
just rejects every ?next=. Tests updated to mock the new attribute.
@awais786 awais786 force-pushed the feat/portal-sign-out-endpoint branch from 5ee0985 to c1f3b36 Compare May 18, 2026 12:46
return HttpResponseBadRequest(
"next= target host is not a subdomain of PLATFORM_DOMAIN"
)
return HttpResponseRedirect(next_url)
@awais786 awais786 force-pushed the feat/portal-sign-out-endpoint branch from c1f3b36 to ea370b0 Compare May 18, 2026 12:50
Per review feedback: Plane already has SignOutAuthEndpoint that calls
django.contrib.auth.logout(). Adding a parallel PortalSignOutEndpoint
duplicated logic. Fold the cross-origin GET variant into the existing
view instead — one endpoint, two methods, single logout primitive.

- signout.py: add GET method with ?next= validation; share
  _flush_session() between POST and GET so both record
  last_logout_time/_ip and call logout() identically.
- urls.py: drop the separate /portal-sign-out/ path; both methods
  now hit /auth/sign-out/.
- portal_signout.py: deleted (functionality merged).
- test_signout.py: add GET-side tests (allowlisted next, disallowed
  host, dot-boundary, empty PLATFORM_DOMAIN, fallback to
  MPASS_SIGNOUT_URL, malformed next).
- test_portal_signout.py: deleted (tests merged into test_signout.py).

Net: -95 lines vs the prior commit; single source of logout truth.
@awais786 awais786 force-pushed the feat/portal-sign-out-endpoint branch from ea370b0 to d695600 Compare May 18, 2026 12:52
@awais786

Copy link
Copy Markdown
Author

Closing — too much force-push churn. Reopening as fresh PR with a clean single-commit history.

@awais786 awais786 closed this May 18, 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.

2 participants