Skip to content

Bug: Logout Does Not Terminate Keycloak SSO Session — User Immediately Re-Authenticated #490

@WPrintz

Description

@WPrintz

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

  1. Log in to Registry via Keycloak
  2. Click "Logout"
  3. Observe redirect to login page (appears logged out)
  4. Click "Login via Keycloak"
  5. Actual: Immediately logged back in as previous user — no password prompt
  6. 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.pyget_current_user() username No
registry/auth/dependencies.pyenhanced_auth() username, groups, auth_method, provider No
registry/auth/routes.pylogout_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) — uses KEYCLOAK_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_hint

Change 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, no access_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions