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/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 8872348..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 @@ -274,8 +274,20 @@ 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 + 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(