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
8 changes: 6 additions & 2 deletions docs/docs/manage/sso-microsoft-entra-id-tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,12 @@ Front-channel logout enables automatic session clearing when users log out from
- Production: `https://gateway.yourcompany.com/admin/logout`
- Development: `http://localhost:8000/admin/logout`

2. When users log out from Microsoft, Entra ID sends a GET request to this URL
3. ContextForge clears the session cookie and returns HTTP 200
2. **How it works**: The `/admin/logout` endpoint supports three scenarios:
- **OIDC front-channel logout**: When users log out from Microsoft Entra ID, it sends a GET request without browser headers. ContextForge clears the session and returns HTTP 200 (per OpenID Connect Front-Channel Logout 1.0 spec).
- **Browser navigation**: If a user navigates directly to `/admin/logout` in their browser (GET with `Accept: text/html` header), they are redirected to the login page.
- **User-initiated logout**: POST requests from the Admin UI logout button redirect to the login page after clearing the session.

3. All three scenarios properly clear authentication cookies and SSO session state.

## Step 5: Configure ContextForge Environment

Expand Down
35 changes: 27 additions & 8 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4114,13 +4114,15 @@ async def _admin_logout(request: Request) -> Response:
"""
Handle admin logout by clearing authentication cookies.

Supports both GET and POST methods:
- POST: User-initiated logout from the UI (redirects to login page)
- GET: OIDC front-channel logout from identity provider (returns 200 OK)
Supports three logout scenarios:
- POST: User-initiated logout from the UI (redirects to login page or Keycloak logout)
- GET with browser headers: Browser navigation to /admin/logout (redirects to login page)
- GET without browser headers: OIDC front-channel logout callback from IdP (returns 200 OK)

For OIDC front-channel logout, Microsoft Entra ID sends GET requests to notify
the application that the user has logged out from the IdP. The application
should clear the session and return HTTP 200.
For OIDC front-channel logout (per OpenID Connect Front-Channel Logout 1.0 spec),
identity providers like Microsoft Entra ID send GET requests to notify the application
that the user has logged out from the IdP. The application should clear the session
and return HTTP 200.

Args:
request (Request): FastAPI request object.
Expand Down Expand Up @@ -4262,10 +4264,27 @@ def _build_keycloak_logout_url(root_path: str) -> Optional[str]:
LOGGER.info(f"Admin user logging out (method: {request.method})")
root_path = _resolve_root_path(request)

# For GET requests (OIDC front-channel logout), return 200 OK per OIDC spec.
# For GET requests, distinguish between browser navigation and OIDC front-channel logout
if request.method == "GET":
response = Response(content="Logged out", status_code=200)
# Check if request is from a browser (Accept: text/html, HX-Request header, or admin referer)
# Detection must match auth_middleware.py and rbac.py patterns to ensure consistent behavior
# Browser navigation should redirect to login, OIDC callbacks should return 200 OK
accept_header = request.headers.get("accept", "")
is_htmx = request.headers.get("hx-request") == "true"
referer = request.headers.get("referer", "")
is_browser_request = "text/html" in accept_header or is_htmx or "/admin" in referer

if is_browser_request:
# Browser navigation - redirect to login (cookies cleared below)
response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
else:
# OIDC front-channel logout from IdP - return 200 OK per OIDC spec
# Reference: OpenID Connect Front-Channel Logout 1.0
# https://openid.net/specs/openid-connect-frontchannel-1_0.html
# The RP must clear the session and return HTTP 200 to acknowledge logout
response = Response(content="Logged out", status_code=200)
else:
# POST requests (user-initiated) - redirect to login (cookies cleared below)
response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303)

auth_provider = await _extract_auth_provider_from_jwt_cookie()
Expand Down
46 changes: 43 additions & 3 deletions tests/unit/mcpgateway/test_admin_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,16 +471,56 @@ async def test_admin_login_handler_default_password(monkeypatch):

@pytest.mark.asyncio
async def test_admin_logout_paths():
# POST request should always redirect to login
post_request = _make_request(root_path="/root")
post_request.method = "POST"
response = await admin._admin_logout(post_request)
assert isinstance(response, RedirectResponse)
assert response.status_code == 303
assert response.headers["location"] == "/root/admin/login"

# GET request with Accept: text/html (browser) should redirect to login
get_browser_request = _make_request(root_path="/root")
get_browser_request.method = "GET"
get_browser_request.headers = {"accept": "text/html,application/xhtml+xml"}
response = await admin._admin_logout(get_browser_request)
assert isinstance(response, RedirectResponse)
assert response.status_code == 303
assert response.headers["location"] == "/root/admin/login"

# GET request without Accept: text/html (OIDC front-channel) should return 200 OK
get_oidc_request = _make_request(root_path="/root")
get_oidc_request.method = "GET"
get_oidc_request.headers = {"accept": "application/json"}
response = await admin._admin_logout(get_oidc_request)
assert response.status_code == 200
assert response.body == b"Logged out"

# GET request with HX-Request header (HTMX) should redirect to login
get_htmx_request = _make_request(root_path="/root")
get_htmx_request.method = "GET"
get_htmx_request.headers = {"accept": "application/json", "hx-request": "true"}
response = await admin._admin_logout(get_htmx_request)
assert isinstance(response, RedirectResponse)
assert response.status_code == 303
assert response.headers["location"] == "/root/admin/login"

# GET request with admin referer should redirect to login
get_referer_request = _make_request(root_path="/root")
get_referer_request.method = "GET"
get_referer_request.headers = {"accept": "application/json", "referer": "http://localhost:4444/admin/users"}
response = await admin._admin_logout(get_referer_request)
assert isinstance(response, RedirectResponse)
assert response.status_code == 303
assert response.headers["location"] == "/root/admin/login"

get_request = _make_request(root_path="/root")
get_request.method = "GET"
response = await admin._admin_logout(get_request)
# GET request with */* Accept header (no text/html) should return 200 OK (OIDC)
get_wildcard_request = _make_request(root_path="/root")
get_wildcard_request.method = "GET"
get_wildcard_request.headers = {"accept": "*/*"}
response = await admin._admin_logout(get_wildcard_request)
assert response.status_code == 200
assert response.body == b"Logged out"


@pytest.mark.asyncio
Expand Down
Loading