Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<AUTH_ISSUER_URL>/logout?client_id=<AUTH_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

Expand Down
16 changes: 14 additions & 2 deletions tests/test_central_auth_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down