From 60120058912290e64db4c8fdc0d9d3076752a3ee Mon Sep 17 00:00:00 2001 From: jzhao234 Date: Wed, 16 Sep 2026 02:30:39 +0000 Subject: [PATCH 1/2] fix(auth): signing out of Explorer signs out of every Elcano app TLDR Explorer's logout used to end only the Explorer session and show its own signed-out page; the central Auth session stayed alive, so the next click on any app signed the user straight back in. Logout now hands the browser to Auth's sign-out endpoint, which ends the central session, tells every registered application to drop its sessions, and lands on Auth's login page. Problem POST /logout revoked the local session and redirected to /signed-out. With Fleet's silent SSO start (FLEET_OIDC_AUTO_START) a user who logged out of Explorer and opened Fleet was signed in again without a prompt, and Explorer itself re-signed them in on the next visit. "Log out" therefore appeared to do nothing, which is the owner's report. Fix - After revoking the local session and clearing the cookie, redirect to `/logout?client_id=` (Auth's RP-initiated logout, ElcanoTek/auth#36). Auth revokes every central session of the account, fans a signed back-channel logout out to every application (Explorer included; idempotent), and shows its login page with a notice. - /signed-out stays as a plain page for direct visits. - Requires Auth main 9f36ed8+ with #36; against an older Auth the GET is a 405, so deploy Auth first. Tests - test_logout_requires_csrf_and_revokes_only_explorer_session now asserts the 303 targets https://auth.example.com/logout?client_id=explorer. - ruff check, ruff format --check, full suite 113 passed. --- app/main.py | 12 ++++++++++-- tests/test_central_auth_endpoints.py | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 5430a4e..2fbb44b 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from datetime import date from pathlib import Path from typing import Any, cast -from urllib.parse import urlencode, urlsplit +from urllib.parse import quote, urlencode, urlsplit from uuid import uuid4 from fastapi import FastAPI, Form, HTTPException, Query, Request @@ -726,7 +726,15 @@ def logout(request: Request, csrf_token: str | None = Form(default=None)): raise HTTPException(status_code=403, detail="Invalid CSRF token") provider.store.revoke_session(raw_token) request.session.clear() - response = RedirectResponse(url="/signed-out", status_code=303) + # Logging out means signing out of every Elcano app: hand the browser to + # Auth's RP-initiated logout, which ends the central session, fans a + # back-channel logout out to every registered application (this one + # included, harmlessly), and lands on Auth's login page. /signed-out + # remains for direct visits. + response = RedirectResponse( + url=f"{provider.client.issuer_url}/logout?client_id={quote(provider.client.client_id, safe='')}", + status_code=303, + ) provider.clear_session_cookie(response) return response diff --git a/tests/test_central_auth_endpoints.py b/tests/test_central_auth_endpoints.py index 8872348..9660901 100644 --- a/tests/test_central_auth_endpoints.py +++ b/tests/test_central_auth_endpoints.py @@ -274,7 +274,13 @@ def test_logout_requires_csrf_and_revokes_only_explorer_session( response = client.post("/logout", data={"csrf_token": csrf}, follow_redirects=False) assert response.status_code == 303 - assert response.headers["location"] == "/signed-out" + # Logout hands the browser to Auth's RP-initiated logout so the central + # session (and every other app session) ends too; otherwise the next + # visit would silently sign the user straight back in. + assert ( + response.headers["location"] + == "https://auth.example.com/logout?client_id=explorer" + ) assert store.get_identity(raw_token) is None From 6e31683b09ff259d580f4c5304598f38ad49ae4a Mon Sep 17 00:00:00 2001 From: jzhao234 Date: Wed, 16 Sep 2026 02:48:34 +0000 Subject: [PATCH 2/2] docs/tests: describe logout as sign-out-everywhere and assert the cookie deletion DEPLOYMENT.md said normal logout stayed scoped to the Explorer session; the test was named for the old contract. Both now match the redirect to Auth's RP-initiated logout, and the test asserts the app cookie is deleted. --- docs/DEPLOYMENT.md | 6 +++++- tests/test_central_auth_endpoints.py | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 242b419..6632c69 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -228,7 +228,11 @@ subject, and replay ID before revoking every local session for that central subject. Delivery is idempotent, so Auth can retry safely after outages. Account disablement, password replacement, and an explicit central sign-out-everywhere take effect without waiting for Explorer's idle timeout. -Normal Explorer logout remains scoped to the current Explorer session. +Explorer's own logout is that sign-out-everywhere: it revokes the local +session, clears the cookie, and redirects to +`/logout?client_id=`, where Auth ends every +central session of the account and fans the logout out to every application. +The browser lands on Auth's login page. #### Replacing the removed local-password mode diff --git a/tests/test_central_auth_endpoints.py b/tests/test_central_auth_endpoints.py index 9660901..c28c265 100644 --- a/tests/test_central_auth_endpoints.py +++ b/tests/test_central_auth_endpoints.py @@ -261,7 +261,7 @@ def test_central_mode_rejects_legacy_and_old_local_cookies(central_client) -> No assert response.headers["location"].startswith("/auth/login?next=") -def test_logout_requires_csrf_and_revokes_only_explorer_session( +def test_logout_requires_csrf_then_signs_out_everywhere_via_auth( central_client, ) -> None: client, store = central_client @@ -282,6 +282,12 @@ def test_logout_requires_csrf_and_revokes_only_explorer_session( == "https://auth.example.com/logout?client_id=explorer" ) assert store.get_identity(raw_token) is None + deleted = [ + value + for value in response.headers.get_list("set-cookie") + if value.startswith(f"{CENTRAL_AUTH_COOKIE_NAME}=") and "Max-Age=0" in value + ] + assert deleted, response.headers.get_list("set-cookie") def test_signed_backchannel_logout_revokes_all_sessions_for_subject(