Skip to content

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

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

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

Conversation

@awais786
Copy link
Copy Markdown

Summary

Adds `GET /auth/portal-logout?next=` so the foss-server-bundle portal's "Log out of all apps" button can include Outline in its cross-origin redirect chain.

Same pattern as Pressingly/plane#35. Each app in the chain clears its own native cookies while the browser is on that app's origin (cross-origin Set-Cookie is impossible from the portal).

What it does

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

  • Clears `accessToken` cookie (path `/`, `expires=epoch`) — load-bearing path attr; without it the default `/auth/portal-logout` scope doesn't shadow the original cookie.
  • Clears `lastSignedIn` cookie same way.
  • Validates `?next=` host against `PLATFORM_DOMAIN` (suffix-match + dot boundary).
  • 302s to `next` if allowlisted; otherwise 200 with cookies still cleared.
  • CSRF-exempt (no token shared cross-origin); residual force-logout risk acknowledged.

Files

File Net Purpose
`server/routes/auth/index.ts` +87 `/portal-logout` handler + `isAllowedSignOutNext` validator
`server/routes/auth/index.test.ts` +149 7 new cases
`server/env.ts` +16 `PLATFORM_DOMAIN` env field with JSDoc

Total: +252 lines.

Tests

7 cases:

  1. Cookies cleared at `path=/` on every call
  2. 302 to allowlisted `next`
  3. Subdomain matches `PLATFORM_DOMAIN`
  4. Reject `next` on host outside allowlist
  5. Dot-boundary enforcement (`foss.arbisoft.com.evil` rejected)
  6. Unset `PLATFORM_DOMAIN` rejects every `next`
  7. Malformed `next` rejected

Design choice: cookie-clear only, no JWT rotation

The existing `POST /api/auth.delete` rotates the user's JWT secret (invalidates every device's JWT). The new `GET /portal-logout` only clears this browser's cookies and does not rotate.

Rationale: GET endpoint is force-logout-able (an `` triggers it). If we rotated the JWT secret on every GET hit, an attacker's force-logout would kick the victim out of all their devices, not just this browser. Cookie-only clear keeps the blast radius to "current browser only" while still serving the portal chain's purpose. ForwardAuth blocks reuse of stale cookies anyway.

If stronger per-user invalidation is needed later, can be added as a follow-up.

Out of scope

  • Portal-side button wiring — separate PR in foss-server-bundle / foss-server-bundle-devstack.
  • Devstack env passthrough (`PLATFORM_DOMAIN: ${PLATFORM_DOMAIN}` for the Outline container) — separate small PR.
  • Analogous endpoints on Penpot / Twenty / SurfSense — separate PRs.

History

Previously opened as #22, closed (was based on pre-PR-#19 foss-main). Rebased on current foss-main and reopened.

awais786 and others added 3 commits May 18, 2026 18:44
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>
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>

const nextRaw = String(ctx.query.next ?? "").trim();
if (nextRaw && isAllowedSignOutNext(nextRaw)) {
ctx.redirect(nextRaw);
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a portal-driven logout endpoint so Outline can participate in a cross-app logout redirect chain by clearing its own cookies on its own origin before optionally redirecting to the next app.

Changes:

  • Adds GET /auth/portal-logout to clear accessToken and lastSignedIn.
  • Adds redirect validation against PLATFORM_DOMAIN.
  • Adds tests for cookie clearing, allowlisted redirects, rejected redirects, malformed URLs, and missing next.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
server/routes/auth/index.ts Adds portal logout handler and redirect target validation.
server/routes/auth/index.test.ts Adds endpoint coverage for redirect and cookie-clearing behavior.
server/env.ts Adds PLATFORM_DOMAIN configuration used for redirect allowlisting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +117 to +130
// Reject non-http(s) schemes outright — javascript: and data: parse fine
// but should never be a valid next-hop. https is required in production;
// http is allowed for dev/localhost flows.
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return false;
}
const host = parsed.hostname.toLowerCase();
if (!host) {
return false;
}
const platformDomain = env.PLATFORM_DOMAIN;
if (!platformDomain) {
return false;
}
Comment on lines +167 to +172
ctx.cookies.set("lastSignedIn", "", {
httpOnly: false,
sameSite: "lax",
expires: epoch,
path: "/",
});
if (!platformDomain) {
return false;
}
return host === platformDomain || host.endsWith("." + platformDomain);
Comment thread server/env.ts
Comment on lines +551 to +553
.toLowerCase()
.replace(/^\.+/, "")
.trim();
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.

3 participants