Skip to content

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

Closed
awais786 wants to merge 1 commit into
foss-mainfrom
feat/portal-sign-out-endpoint
Closed

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

Conversation

@awais786

Copy link
Copy Markdown

Summary

Lets the foss-server-bundle portal's "Log out of all apps" button include Plane in the redirect chain. Existing POST /auth/sign-out/ is CSRF-protected, so the portal can't redirect into it cross-origin. This adds a GET variant that the portal can navigate the browser through.

The portal builds a nested chain like:

window.location =
  'https://pm.<domain>/auth/portal-sign-out/?next=' + encodeURIComponent(
    'https://docs.<domain>/auth/portal-logout?next=' + encodeURIComponent(
      'https://paint.<domain>/...?next=' + encodeURIComponent(
        '/oauth2/sign_out?rd=' + encodeURIComponent(
          'https://<cognito>/logout?client_id=...&logout_uri=<portal>'
        )
      )
    )
  );

Each app clears its own session cookie while the browser is on that app's domain (so Set-Cookie scope is correct), and the final hop lands at the portal.

Endpoint

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

  1. logout(request) — flushes the Django session
  2. Validates next against the env-configured allowlist
  3. 302 to next (or to MPASS_SIGNOUT_URL if next is omitted)

CSRF-exempt — the portal can't share a CSRF token cross-origin. Residual risk is force-logout (an attacker embeds <img src=".../portal-sign-out"> and the victim's session ends). Low impact: the only state lost is the session itself, and ForwardAuth re-auths on the next request.

New setting

# Comma-separated host suffixes. Each entry matches its exact host plus all
# subdomains. Empty list rejects any ?next= (the endpoint still flushes the
# session — it just won't redirect).
MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS=foss.arbisoft.com,localhost

Suffix match enforces a dot boundary, so foss.arbisoft.com matches pm.foss.arbisoft.com but not foss.arbisoft.com.evil.example — closes the obvious open-redirect surface.

Test plan

  • pytest apps/api/plane/tests/unit/views/test_portal_signout.py -v passes — covers:
    • Valid next= on allowlisted host → 302 + logout() called
    • No next= → falls back to MPASS_SIGNOUT_URL
    • No next= and no MPASS_SIGNOUT_URL → 302 to /
    • next= on disallowed host → 400
    • Suffix-match dot boundary (foss.arbisoft.com.evil rejected)
    • Subdomain (docs.foss.arbisoft.com) matches the suffix entry
    • Empty allowlist rejects all ?next=
    • Malformed ?next= (no hostname) → 400
    • Allowlist entry written as .foss.arbisoft.com (with leading dot) is normalised
  • Manual: curl GET /auth/portal-sign-out/?next=https://docs.foss.arbisoft.com/auth/portal-logout with a Django session cookie — verify session cookie is cleared in the response and Location header is the next URL.
  • Manual: same curl but with next=https://evil.example/x — verify 400.

Relation to other PRs

Open follow-up

Outline / Penpot / Twenty will each need an analogous endpoint in their own session technology to fully participate in the portal redirect chain. Those PRs are separate.

🤖 Generated with Claude Code

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>
return HttpResponseBadRequest(
"next= target host is not in MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS"
)
return HttpResponseRedirect(next_url)
@awais786

Copy link
Copy Markdown
Author

Closing — the portal-driven logout chain (where the portal redirects through each app's logout URL) was an alternative to middleware self-healing. Going with #29's middleware mismatch detection as the load-bearing fix; portal can keep doing what it already does (clear _oauth2_proxy + Cognito) and Plane self-corrects on the next request. Can revisit if synchronous cleanup becomes a hard requirement.

@awais786 awais786 closed this May 16, 2026
awais786 added a commit to Pressingly/outline that referenced this pull request May 18, 2026
Lets the foss-server-bundle portal's "Log out of all apps" button
include Outline in the redirect chain. Browser security forbids
cross-origin Set-Cookie, so the portal cannot clear Outline's
accessToken cookie on docs.<domain> directly — it has to navigate the
browser to a same-origin Outline URL that clears the cookie itself.

GET /auth/portal-logout?next=<absolute_url>
  1. Clears accessToken + lastSignedIn cookies (same shape as the
     auth() catch block already uses).
  2. Validates next against MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS — suffix
     match on a dot boundary, so foss.arbisoft.com matches its
     subdomains but NOT foss.arbisoft.com.evil.example.
  3. 302s to next, or returns 200 if next is missing/invalid (cookies
     still cleared).

CSRF-exempt because the portal cannot share Outline's CSRF token
cross-origin. Residual risk is force-logout (img-tag embedding) — low
impact: ForwardAuth re-auths on the next request.

Empty allowlist rejects every ?next= — endpoint still clears cookies,
just won't redirect. Non-SSO deployments unaffected by default.

Mirrors Pressingly/plane#31 for the same flow on Plane's side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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