-
Notifications
You must be signed in to change notification settings - Fork 95
Description
Bug: Logout Does Not Terminate Keycloak SSO Session — User Immediately Re-Authenticated
Description
When a user clicks "Logout" in the Registry UI, the local session cookie (mcp_gateway_session) is cleared and the user is redirected to the login page. However, when they click "Login via Keycloak", they are immediately redirected back to the Registry as the previous user without being prompted for credentials.
This happens because the logout flow does not properly terminate the Keycloak SSO session. Keycloak maintains its own session cookie on its domain, and without receiving id_token_hint at its OIDC logout endpoint, it cannot identify which session to invalidate.
Steps to Reproduce
- Log in to Registry via Keycloak
- Click "Logout"
- Observe redirect to login page (appears logged out)
- Click "Login via Keycloak"
- Actual: Immediately logged back in as previous user — no password prompt
- Expected: Keycloak should display login form
Confirmation: Manually deleting the Keycloak SSO cookie (on the Keycloak/CloudFront domain) forces re-authentication, confirming the SSO session persistence is the root cause.
Root Cause Analysis
The logout flow has three hops. The third fails silently:
1. Registry logout_handler (registry/auth/routes.py:204)
→ Clears mcp_gateway_session cookie ✅
→ Redirects to auth-server /oauth2/logout/{provider}
2. Auth-server oauth2_logout (auth_server/server.py:2744)
→ Builds Keycloak logout URL with client_id + post_logout_redirect_uri ✅
→ Redirects browser to Keycloak
3. Keycloak OIDC logout endpoint
→ Receives: client_id, post_logout_redirect_uri
→ Missing: id_token_hint ❌
→ Cannot identify which session to terminate
→ SSO session cookie remains active
Why id_token_hint is missing: During the OAuth callback (auth_server/server.py:2500-2520), the id_token is received from Keycloak and decoded to extract user claims, but it is never stored in the session cookie. At logout time, there is no id_token available to pass as id_token_hint.
The session cookie stores access_token and refresh_token but not id_token:
# auth_server/server.py:2586-2599
session_data = {
"username": mapped_user["username"],
"email": mapped_user.get("email"),
"name": mapped_user.get("name"),
"groups": mapped_user.get("groups", []),
"provider": provider,
"auth_method": "oauth2",
"access_token": token_data.get("access_token"), # ← stored but unused
"refresh_token": token_data.get("refresh_token"), # ← stored but unused
"token_expires_in": token_data.get("expires_in"),
"token_obtained_at": int(time.time()),
# id_token: NOT STORED ← root cause
}The string id_token_hint does not appear anywhere in the codebase.
Key Finding: access_token and refresh_token in Session Are Unused
An exhaustive search of every session cookie consumer in the registry/ codebase shows that access_token and refresh_token are never read back from the session for any functional purpose. They are dead weight in the cookie.
Every session consumer and what fields they read:
| Location | Fields Read | Reads access_token/refresh_token? |
|---|---|---|
registry/auth/dependencies.py — get_current_user() |
username |
No |
registry/auth/dependencies.py — enhanced_auth() |
username, groups, auth_method, provider |
No |
registry/auth/routes.py — logout_handler() |
auth_method, provider |
No |
registry/health/routes.py — WebSocket auth |
username |
No |
registry/audit/middleware.py |
Cookie presence only | No |
registry/api/server_routes.py:2647-2648 — token generation |
Logs as booleans only | Logged, never used as values |
"Get JWT Token" does NOT use stored tokens: The token generation endpoint (registry/api/server_routes.py:2656-2670) passes only user_context (username, email, groups, scopes, provider, auth_method) to the auth-server's /internal/tokens endpoint. The auth-server (auth_server/server.py:1878-1910) generates a self-signed JWT from that context using SECRET_KEY. The stored OAuth tokens are never involved.
Other subsystems use their own credentials:
- Keycloak manager (
registry/utils/keycloak_manager.py:42-50) — usesKEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD - Entra ID manager (
registry/utils/entra_manager.py:99) — uses its own client credentials - Federation auth (
registry/services/federation/federation_auth.py:192) — generates its own M2M tokens
Proposed Fix
Replace the unused access_token and refresh_token in the session cookie with id_token, then pass it as id_token_hint during logout.
Cookie size impact (measured from live deployment):
Current: 2,258 bytes (with unused access_token 1,687 chars + refresh_token 715 chars)
Proposed: ~1,600-1,900 bytes (with id_token only, ~1,200-1,500 chars estimated)
Browser limit: 4,096 bytes
The cookie gets smaller, not larger.
Change 1: Store id_token in session, remove unused tokens
File: auth_server/server.py ~line 2586-2599
# BEFORE:
session_data = {
...
"access_token": token_data.get("access_token"),
"refresh_token": token_data.get("refresh_token"),
...
}
# AFTER:
session_data = {
...
"id_token": token_data.get("id_token"),
...
}Change 2: Pass id_token from Registry logout to auth-server
File: registry/auth/routes.py — in logout_handler() ~line 250
Extract id_token from session data and append as id_token_hint query parameter when redirecting to the auth-server's /oauth2/logout/{provider} endpoint.
Change 3: Forward id_token_hint to IdP logout URL
File: auth_server/server.py — in oauth2_logout() ~line 2744
Accept id_token_hint as a query parameter and include it in the Keycloak/Entra ID logout URL:
if "keycloak" in provider.lower() or "/realms/" in logout_url:
logout_params = {
"client_id": provider_config["client_id"],
"post_logout_redirect_uri": full_redirect_uri,
}
if id_token_hint:
logout_params["id_token_hint"] = id_token_hintChange 4: Update diagnostic logging
File: registry/api/server_routes.py ~line 2647-2648
Update the boolean log from has_access_token/has_refresh_token to has_id_token.
Impact on Other IdPs
| IdP | Effect |
|---|---|
| Keycloak | Fixes SSO logout bug |
| Entra ID | Improves logout — eliminates confirmation page, enables silent logout |
| Cognito | No impact — uses logout_uri param, ignores id_token_hint |
| GitHub/Google | No impact — id_token_hint ignored if unsupported |
What This Does NOT Break
- ✅ "Get JWT Token" — generates self-signed tokens from user context, not stored OAuth tokens
- ✅ All normal Registry operations (browsing, searching, registering servers/agents)
- ✅ Federation sync
- ✅ User/group management (Keycloak admin, Entra ID)
- ✅ Audit logging
- ✅ WebSocket health checks
Testing Checklist
- Login via Keycloak → verify cookie contains
id_token, noaccess_token/refresh_token - Logout → click "Login via Keycloak" → must prompt for password
- "Get JWT Token" button → must return a valid token
- Generated token works for MCP Gateway access
- Cookie size < 4,096 bytes
- Login via Entra ID (if configured) → logout terminates session
- Login via Cognito (if configured) → logout still works
Related Issues
- feat: Implement server-side token storage to resolve session cookie size limit #399 — Server-side token storage (addresses cookie size holistically)
- Registry Web UI: Logout results in error page #56, Registry Web UI: Logout results in Cognito error page #57 — Previous logout bugs with Cognito (closed)
- Keycloak Integration: Add Self-Hosted Authentication Provider Support #114 — Keycloak integration design (closed)