diff --git a/docs/auth-cross-host.md b/docs/auth-cross-host.md
index 3e754ab..280e262 100644
--- a/docs/auth-cross-host.md
+++ b/docs/auth-cross-host.md
@@ -54,10 +54,30 @@ auth, root beacon, custom org domains) stores `login_id` in its scs `data` blob
and is validated against the parent on each request.
- **Logout = one write:** set `revoked_at` on the `logins` row. Every host drops
- to anonymous on its next request — including custom org domains, which the old
+ to anonymous on its next request (`ValidateSession` destroys any session whose
+ backing login is revoked) — including custom org domains, which the old
redirect-chain logout never covered.
- Per-device logout = revoke one row; "log out everywhere" = revoke all of a
user's rows.
- Do **not** add per-host token columns to the scs `sessions` table — scs
resolves a cookie by its single `token` PK, and the host set is unbounded
(every custom domain is another host).
+
+### Logout is a per-host POST, never a cross-host GET chain
+
+Sign-out is a **POST** on the host that holds the session (app, auth, or a custom
+org domain). That handler revokes the shared login row and lands the visitor on
+the public root. Because a single real sign-in maps to exactly one login row
+(the handoff callbacks reuse it via `loginWithID`), revoking it once on *any* of
+those hosts drops all of them on their next request — so there is **no** redirect
+chain from the app host to an auth-host `/logout`. The auth host's own POST
+`/logout` still exists to cover the auth-local case: a visitor who authenticated
+on the auth host (e.g. for account settings) with no app session signs out there
+directly.
+
+`/logout` is **POST-only** on every host — state-changing actions are POST
+(rule 3's spirit applied everywhere), so a forged cross-site GET like
+`` cannot sign anyone out. The template picks the right
+target via `LogoutURL` (relative `/logout` on mutating surfaces); the **root
+host** is side-effect-free GET and therefore has no `/logout` at all — its chrome
+hides the sign-out control and the user signs out from the app dashboard.
diff --git a/internal/auth/web.go b/internal/auth/web.go
index b463783..3c13372 100644
--- a/internal/auth/web.go
+++ b/internal/auth/web.go
@@ -43,8 +43,10 @@ func (s *Handlers) Routes() chi.Router {
s.baseMW(r)
s.SharedRoutes(r)
s.credentialRoutes(r)
- // Single logout: clears the SSO session (chained to from the app host).
- r.Get("/logout", s.handleAuthLogout)
+ // Logout: revokes the login row (dropping every host to anonymous on its next
+ // request) and clears this host's SSO session. POST-only — sign-out is a state
+ // change, so a forged cross-site GET can't trigger it.
+ r.Post("/logout", s.handleAuthLogout)
// Account/credential management lives here so credentials are created on the
// same origin used to sign in. Guarded by the SSO session.
@@ -118,16 +120,11 @@ func (s *Handlers) pageSignup(w http.ResponseWriter, r *http.Request) {
})
}
-// handleAuthLogout clears the auth host's SSO session and lands the visitor on
-// the public root (or the app in narrower configs).
+// handleAuthLogout revokes the login row backing this session and lands the
+// visitor on the public root. Revoking the shared row is what signs the user out
+// of every host (app, root beacon, custom org domains) on their next request; it
+// also directly clears an auth-local SSO session that never had an app session.
func (s *Handlers) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
_ = s.Auth.Logout(r.Context())
- switch {
- case s.Cfg.RootHost != "":
- http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
- case s.Cfg.PrimaryHost != "":
- http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
- default:
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- }
+ s.RedirectAfterLogout(w, r)
}
diff --git a/internal/core/handoff_test.go b/internal/core/handoff_test.go
index 6ae21f2..6dc87e4 100644
--- a/internal/core/handoff_test.go
+++ b/internal/core/handoff_test.go
@@ -240,51 +240,115 @@ func TestHostRouting(t *testing.T) {
})
}
-// TestSingleLogout verifies sign-out clears BOTH sessions: the app host's
-// /logout chains to the auth host, and the auth host's /logout destroys the SSO
-// session so it can't silently re-authenticate.
+// TestSingleLogout verifies the single-revoke logout model: one POST to a host's
+// /logout revokes the shared login row, dropping every host bound to it. An
+// auth-host sign-in is handed off to the app so both sessions share one login row;
+// logging out on the APP host then invalidates the AUTH host's SSO session on its
+// next request — with no redirect chain between them. See docs/auth-cross-host.md.
func TestSingleLogout(t *testing.T) {
t.Parallel()
- st, ctx, ts, h := splitServer(t)
+ _, _, ts, h := splitServer(t)
- // An app session (via the handoff) logs out by bouncing to the auth host.
- u, _ := st.CreateUser(ctx, "logoutuser", "")
- loginID, _ := st.CreateLogin(ctx, u.ID)
- code, _ := st.CreateAuthCode(ctx, u.ID, loginID, "/")
+ // Sign up on the auth host: creates login row L and the auth SSO session, then
+ // hands off to the app with a single-use code carrying L.
+ su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"sharuser"}, "password": {"supersecret"}})
+ su.Body.Close()
+ sso := cookieByName(su, "meshtender_session")
+ if sso == nil {
+ t.Fatal("expected an SSO session from signup")
+ }
+ loc, _ := url.Parse(su.Header.Get("Location"))
+ code := loc.Query().Get("code")
+ if code == "" {
+ t.Fatalf("signup did not hand off to the app (Location %q)", su.Header.Get("Location"))
+ }
+ // Redeem the code on the app host, binding its session to the SAME row L.
cb := do(t, ts, h.app, "/session/callback?code="+code+"&state=s1", &http.Cookie{Name: "mt_state", Value: "s1"})
cb.Body.Close()
appSess := cookieByName(cb, "meshtender_session")
if appSess == nil {
t.Fatal("expected an app session from the callback")
}
- out := post(t, ts, h.app, "/logout", url.Values{}, appSess)
- out.Body.Close()
- if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Host != h.auth || loc.Path != "/logout" {
- t.Fatalf("app logout = %d %q, want 303 to auth /logout", out.StatusCode, out.Header.Get("Location"))
+
+ // Both live: auth /login re-hands-off (303) rather than showing the form.
+ live := do(t, ts, h.auth, "/login?next=%2F&state=x", sso)
+ live.Body.Close()
+ if live.StatusCode != http.StatusSeeOther {
+ t.Fatalf("pre-logout auth /login = %d, want 303 handoff (SSO live)", live.StatusCode)
}
- // Sign up on the auth host to hold an SSO session, then confirm it is live
- // (auth /login hands off) until /logout, after which it no longer does.
- su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"ssouser"}, "password": {"supersecret"}})
+ // Log out on the APP host: it revokes L and lands on the public root — no hop
+ // through the auth host.
+ out := post(t, ts, h.app, "/logout", url.Values{}, appSess)
+ out.Body.Close()
+ if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Hostname() != testRootHost {
+ t.Fatalf("app logout = %d %q, want 303 to the root host", out.StatusCode, out.Header.Get("Location"))
+ }
+
+ // The AUTH host's SSO session is now dead too: /login shows the form (200)
+ // instead of re-handing-off, because ValidateSession dropped the revoked login.
+ after := do(t, ts, h.auth, "/login", sso)
+ after.Body.Close()
+ if after.StatusCode != http.StatusOK {
+ t.Fatalf("post-logout auth /login = %d, want 200 (SSO cleared via shared-login revoke)", after.StatusCode)
+ }
+}
+
+// TestAuthLogoutClearsSSO covers the auth host's own POST /logout: a visitor who
+// authenticated on the auth host (e.g. for account settings) with no app session
+// signs out there directly, revoking and clearing the SSO session.
+func TestAuthLogoutClearsSSO(t *testing.T) {
+ t.Parallel()
+ _, _, ts, h := splitServer(t)
+
+ su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"ssoonly"}, "password": {"supersecret"}})
su.Body.Close()
sso := cookieByName(su, "meshtender_session")
if sso == nil {
t.Fatal("expected an SSO session from signup")
}
- live := do(t, ts, h.auth, "/login?next=%2F&state=x", sso)
- live.Body.Close()
- if live.StatusCode != http.StatusSeeOther {
- t.Fatalf("authed /login = %d, want 303 handoff (SSO live)", live.StatusCode)
- }
- lo := do(t, ts, h.auth, "/logout", sso)
- lo.Body.Close()
- if lo.StatusCode != http.StatusSeeOther {
- t.Fatalf("auth /logout = %d, want 303", lo.StatusCode)
+
+ out := post(t, ts, h.auth, "/logout", url.Values{}, sso)
+ out.Body.Close()
+ if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Hostname() != testRootHost {
+ t.Fatalf("auth logout = %d %q, want 303 to the root host", out.StatusCode, out.Header.Get("Location"))
}
+
after := do(t, ts, h.auth, "/login", sso)
- defer after.Body.Close()
+ after.Body.Close()
if after.StatusCode != http.StatusOK {
- t.Fatalf("post-logout /login = %d, want 200 (SSO cleared, no re-handoff)", after.StatusCode)
+ t.Fatalf("post-logout auth /login = %d, want 200 (SSO cleared)", after.StatusCode)
+ }
+}
+
+// TestLogoutRejectsGet locks in that /logout is POST-only on both the auth and app
+// hosts: a forged cross-site GET (e.g.
) must not sign
+// anyone out. This is the rule (state-changing actions are POST) the old
+// side-effecting GET /logout violated.
+func TestLogoutRejectsGet(t *testing.T) {
+ t.Parallel()
+ _, _, ts, h := splitServer(t)
+
+ su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"getlogout"}, "password": {"supersecret"}})
+ su.Body.Close()
+ sso := cookieByName(su, "meshtender_session")
+ if sso == nil {
+ t.Fatal("expected an SSO session from signup")
+ }
+
+ for _, host := range []string{h.auth, h.app} {
+ g := do(t, ts, host, "/logout", sso)
+ g.Body.Close()
+ if g.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("GET %s/logout = %d, want 405 (POST-only)", host, g.StatusCode)
+ }
+ }
+
+ // The GET left the session untouched: auth /login still re-hands-off (SSO live).
+ after := do(t, ts, h.auth, "/login?next=%2F&state=x", sso)
+ after.Body.Close()
+ if after.StatusCode != http.StatusSeeOther {
+ t.Fatalf("auth /login after GET /logout = %d, want 303 (SSO still live)", after.StatusCode)
}
}
diff --git a/internal/core/templates/invite.html b/internal/core/templates/invite.html
index 15674ce..84e009d 100644
--- a/internal/core/templates/invite.html
+++ b/internal/core/templates/invite.html
@@ -44,8 +44,7 @@