Skip to content

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

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

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

Conversation

@awais786
Copy link
Copy Markdown

Summary

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.

Endpoint

GET /auth/portal-logout?next=<absolute_url>

  1. Clears accessToken + lastSignedIn cookies (same shape as the auth() catch block already uses for cookie-transported 401s).
  2. Validates next against MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS — suffix match on a dot boundary, so foss.arbisoft.com matches subdomains but not foss.arbisoft.com.evil.example.
  3. 302s to next, or returns 200 + {ok: true} if next is missing/invalid (cookies still cleared either way).

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

New env var

# Comma-separated host suffixes. Each entry matches exact host + all
# subdomains. Empty list rejects every ?next= — cookies still cleared.
MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS=foss.arbisoft.com,localhost

Operators normalise on write: leading dots are stripped, lowercased, blanks filtered.

Portal-side wiring

Once this lands, the portal can do:

window.location =
  'https://docs.<domain>/auth/portal-logout?next=' +
    encodeURIComponent(nextHopUrl);

Or chain through multiple apps:

'https://pm.<domain>/auth/portal-sign-out?next=' +
  encodeURIComponent(
    'https://docs.<domain>/auth/portal-logout?next=' +
      encodeURIComponent(
        '/oauth2/sign_out?rd=' + encodeURIComponent(cognitoLogoutUrl)
      )
  );

Test plan

  • pnpm test server/routes/auth/index.test.ts — 8 new cases in the auth/portal-logout describe block:
    • clears accessToken + lastSignedIn cookies on every call (incl. empty allowlist)
    • 302 to allowlisted next host
    • subdomain match (e.g. docs.foss.arbisoft.com against foss.arbisoft.com)
    • rejects host outside allowlist (200, cookies still cleared)
    • dot-boundary enforcement (rejects foss.arbisoft.com.evil)
    • empty allowlist rejects all ?next=
    • malformed ?next= rejected
    • missing ?next= → 200 + cookies cleared
  • pnpm tsc --noEmit clean
  • Manual: set MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS=foss.arbisoft.com,localhost in dev env, log in to Outline, then curl -i 'http://docs.localhost/auth/portal-logout?next=http://localhost' — expect 302 + Set-Cookie clearing both cookies.

Relation to other work

Risk / rollback

Zero behaviour change for non-SSO deployments: MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS defaults to empty → every ?next= is rejected (cookies cleared, no redirect). To disable post-merge, unset the env var.

🤖 Generated with Claude Code

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>

const nextRaw = String(ctx.query.next ?? "").trim();
if (nextRaw && isAllowedSignOutNext(nextRaw)) {
ctx.redirect(nextRaw);
awais786 and others added 2 commits May 17, 2026 00:32
Two bugs from the review of the initial commit:

1. Cookies were not actually being cleared. Koa's ctx.cookies.set
   defaults Set-Cookie's path to the request URL, so the clear
   Set-Cookie was scoped to /auth/portal-logout instead of /. Browsers
   identify cookies by (name, domain, path) — a path=/auth/portal-logout
   Set-Cookie does not shadow the path=/ accessToken cookie issued at
   login. End-to-end: clicking "Log out of all apps" would 302 cleanly
   but leave the JWT in the browser. Adding explicit path:"/" matches
   the cookie-clear shape already used in auth() catch block.

2. next= validation accepted any URL scheme that new URL() could parse.
   javascript:alert(1) and data: URLs passed (hostname is empty so the
   allowlist still rejected them, but defense-in-depth wants an explicit
   scheme gate). Now: only http: and https: are accepted; everything
   else is rejected before the host check.

Tests:
- New cookieClearedAtRoot regex helper asserts path=/ in Set-Cookie
  attributes — would have caught bug #1.
- New "should reject next with non-http(s) schemes" case covers
  javascript:, data:, file:.
- New "should accept both http and https for allowlisted hosts" case
  pins the allowed scheme set.
- Removed the dead `if (!entry) continue` guard — the env
  normalisation already filters empties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS was redundant — the foss-server-bundle
already sets PLATFORM_DOMAIN (via platform.sh), and every legitimate
next-hop is by definition a subdomain of that. One fewer env var for
operators to set, one fewer place where the bundle config can drift.

Behaviour preserved exactly: PLATFORM_DOMAIN treated as a single host
suffix, matched on a dot boundary. Unset → every ?next= rejected (the
same "safe-fail" the old empty-list case had).

Also tightens a leftover loose cookie-clear assertion the second
review flagged — the "reject host outside allowlist" test now asserts
cookies cleared at path=/, same as its peers.

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