Better Logout Experience #466

Open
opened 2026-03-22 17:14:22 +00:00 by tobru · 1 comment
Owner

Stories

As a user, I want to have a great experience when logging out

Implementation Notes

Right now logging out only logs out of the portal, but not from Keycloak. We should have full blown logout.

## Stories _As a user, I want to have a great experience when logging out_ ## Implementation Notes Right now logging out only logs out of the portal, but not from Keycloak. We should have full blown logout.
Author
Owner

Problem

When a user logs out, only the Django session is cleared. The Keycloak SSO session remains active. This means:

  • Clicking "Sign in" again silently re-authenticates without prompting for credentials
  • On shared computers, the next person using the browser is automatically logged in as the previous user

Root Cause

The current LogoutView (src/servala/frontend/views/auth.py) only calls flows.logout.logout(request), which clears the Django session but does not communicate with Keycloak's OIDC logout endpoint.

Goal

After logout, the Keycloak SSO session should also be terminated so that the next sign-in requires entering credentials again.

Technical Context

  • django-allauth 65.15 has no built-in support for OIDC RP-Initiated Logout
  • django-allauth does not store the raw id_token JWT string anywhere accessible after login - by the time standard hooks fire (pre_social_login), it has been decoded into a dict in SocialAccount.extra_data["id_token"]
  • SOCIALACCOUNT_STORE_TOKENS stores access_token and refresh_token in SocialToken, but not the raw id_token
  • The OIDC end_session_endpoint is available via the OpenID Connect discovery document (already fetched by allauth during login)
  • Keycloak's end_session_endpoint requires the portal's post-logout redirect URI to be registered in the Keycloak client settings under "Valid Post Logout Redirect URIs"

Options

Option A: Store raw id_token in session during login, use as id_token_hint

Subclass OpenIDConnectOAuth2Adapter and override complete_login to save the raw JWT into the Django session before it gets decoded:

class CustomOIDCAdapter(OpenIDConnectOAuth2Adapter):
    def complete_login(self, request, app, token, **kwargs):
        if raw_id_token := kwargs.get("response", {}).get("id_token"):
            request.session["oidc_id_token_raw"] = raw_id_token
        return super().complete_login(request, app, token, **kwargs)

Register a custom OIDC callback URL pattern (before the allauth include) that uses this adapter. At logout, read the raw id_token from the session, then redirect to:

{end_session_endpoint}?id_token_hint={raw_id_token}&post_logout_redirect_uri={portal_url}
  • Pro: Standards-compliant OIDC RP-Initiated Logout. Keycloak silently logs out (no confirmation screen). Provider-agnostic.
  • Pro: The id_token may expire (typically 5-15 min), but Keycloak still accepts expired id_token_hint for logout.
  • Con: Requires overriding the OIDC callback URL pattern and hooking into the login flow.
  • Con: Sessions created before deployment won't have the stored token (needs fallback).

Option B: Use client_id parameter instead of id_token_hint

Since Keycloak 18+, the end_session_endpoint accepts client_id as an alternative to id_token_hint. At logout, redirect to:

{end_session_endpoint}?client_id={client_id}&post_logout_redirect_uri={portal_url}
  • Pro: Zero changes to the login flow. No token storage needed. Build the URL from existing settings (SERVALA_KEYCLOAK_CLIENT_ID).
  • Con: Without id_token_hint, Keycloak may show a "Do you want to log out?" confirmation page instead of silently logging out.
  • Con: The client_id parameter is Keycloak-specific, not part of the OIDC spec.

Option C: Combine both (A with B as fallback)

Store the raw id_token in the session (Option A), but if it's not available (e.g. sessions created before deployment), fall back to client_id (Option B).

  • Pro: Best UX when token is available, graceful degradation otherwise.
  • Con: Two code paths to maintain.

References

### Problem When a user logs out, only the Django session is cleared. The Keycloak SSO session remains active. This means: - Clicking "Sign in" again silently re-authenticates without prompting for credentials - On shared computers, the next person using the browser is automatically logged in as the previous user ### Root Cause The current `LogoutView` (`src/servala/frontend/views/auth.py`) only calls `flows.logout.logout(request)`, which clears the Django session but does not communicate with Keycloak's OIDC logout endpoint. ### Goal After logout, the Keycloak SSO session should also be terminated so that the next sign-in requires entering credentials again. ### Technical Context - django-allauth 65.15 has no built-in support for OIDC RP-Initiated Logout - django-allauth does not store the raw `id_token` JWT string anywhere accessible after login - by the time standard hooks fire (`pre_social_login`), it has been decoded into a dict in `SocialAccount.extra_data["id_token"]` - `SOCIALACCOUNT_STORE_TOKENS` stores `access_token` and `refresh_token` in `SocialToken`, but not the raw `id_token` - The OIDC `end_session_endpoint` is available via the OpenID Connect discovery document (already fetched by allauth during login) - Keycloak's `end_session_endpoint` requires the portal's post-logout redirect URI to be registered in the Keycloak client settings under "Valid Post Logout Redirect URIs" ### Options #### Option A: Store raw `id_token` in session during login, use as `id_token_hint` Subclass `OpenIDConnectOAuth2Adapter` and override `complete_login` to save the raw JWT into the Django session before it gets decoded: ```python class CustomOIDCAdapter(OpenIDConnectOAuth2Adapter): def complete_login(self, request, app, token, **kwargs): if raw_id_token := kwargs.get("response", {}).get("id_token"): request.session["oidc_id_token_raw"] = raw_id_token return super().complete_login(request, app, token, **kwargs) ``` Register a custom OIDC callback URL pattern (before the allauth include) that uses this adapter. At logout, read the raw `id_token` from the session, then redirect to: ``` {end_session_endpoint}?id_token_hint={raw_id_token}&post_logout_redirect_uri={portal_url} ``` - **Pro:** Standards-compliant OIDC RP-Initiated Logout. Keycloak silently logs out (no confirmation screen). Provider-agnostic. - **Pro:** The `id_token` may expire (typically 5-15 min), but Keycloak still accepts expired `id_token_hint` for logout. - **Con:** Requires overriding the OIDC callback URL pattern and hooking into the login flow. - **Con:** Sessions created before deployment won't have the stored token (needs fallback). #### Option B: Use `client_id` parameter instead of `id_token_hint` Since Keycloak 18+, the `end_session_endpoint` accepts `client_id` as an alternative to `id_token_hint`. At logout, redirect to: ``` {end_session_endpoint}?client_id={client_id}&post_logout_redirect_uri={portal_url} ``` - **Pro:** Zero changes to the login flow. No token storage needed. Build the URL from existing settings (`SERVALA_KEYCLOAK_CLIENT_ID`). - **Con:** Without `id_token_hint`, Keycloak may show a "Do you want to log out?" confirmation page instead of silently logging out. - **Con:** The `client_id` parameter is Keycloak-specific, not part of the OIDC spec. #### Option C: Combine both (A with B as fallback) Store the raw `id_token` in the session (Option A), but if it's not available (e.g. sessions created before deployment), fall back to `client_id` (Option B). - **Pro:** Best UX when token is available, graceful degradation otherwise. - **Con:** Two code paths to maintain. ### References - [allauth #3104 - Logging out the client from the identity provider](https://github.com/pennersr/django-allauth/issues/3104) - [allauth #3694 - Get id_token for Keycloak logout](https://codeberg.org/allauth/django-allauth/issues/3694) - [allauth OpenID Connect provider docs](https://docs.allauth.org/en/dev/socialaccount/providers/openid_connect.html) - [Keycloak LogoutEndpoint API](https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.html)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
servala/servala-portal#466
No description provided.