From 3383a0b786cc5b7624076a72c9a7b1c57180fff1 Mon Sep 17 00:00:00 2001 From: Raphael Lullis Date: Mon, 27 Jan 2025 21:52:32 +0100 Subject: [PATCH] OIDC Session management (https://openid.net/specs/openid-connect-session-1_0.html) To enable it, user must add OIDC_SESSION_MANAGEMENT_ENABLED and provide OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY on OAUTH2_PROVIDER settings, and add the proper middleware. This PR contains: - change in AuthorizationView to return 'session_state' parameter in authentication response - a SessionIFrameView as part of the OIDC views, which renders the content of the iframe used by RPs to keep track of session state changes. - middleware that sets the cookie - Documentation - Test for the changed authentication view To help with testing, there are also some improvements to the test RP. - Uses environment variables to allow connecting with other servers beyond localhost:8000 - Monitors authentication session state using the OIDC provider's check_session_iframe endpoint - Periodically checks session status every 5 seconds via postMessage communication with hidden iframe - Handles discovery document fetching and validates provider support for session management - Update docker setup to use Node 22 and provides an optional docker-compose with cloudflared integration. - Vite server configuration to allow configurable hosts for development --- AUTHORS | 1 + CHANGELOG.md | 3 +- docs/oidc.rst | 33 + docs/settings.rst | 18 + oauth2_provider/checks.py | 13 + oauth2_provider/middleware.py | 20 + oauth2_provider/settings.py | 4 + .../templates/oauth2_provider/base.html | 2 + .../oauth2_provider/check_session_iframe.html | 63 ++ oauth2_provider/urls.py | 1 + oauth2_provider/utils.py | 12 + oauth2_provider/views/__init__.py | 8 +- oauth2_provider/views/base.py | 36 +- oauth2_provider/views/oidc.py | 39 +- tests/app/idp/idp/urls.py | 3 +- tests/app/rp/.env.example | 27 + tests/app/rp/Dockerfile | 13 +- tests/app/rp/docker-compose.yml | 35 ++ .../src/lib/components/SessionMonitor.svelte | 580 ++++++++++++++++++ tests/app/rp/src/routes/+layout.svelte | 36 +- tests/app/rp/src/routes/+page.svelte | 114 ++-- tests/app/rp/src/routes/device/+page.svelte | 9 +- tests/app/rp/src/routes/session/+page.svelte | 9 + tests/app/rp/vite.config.ts | 5 +- tests/presets.py | 4 + tests/test_django_checks.py | 15 + tests/test_oauth2_validators.py | 2 +- tests/test_oidc_views.py | 75 ++- tests/test_session_management.py | 51 ++ 29 files changed, 1131 insertions(+), 100 deletions(-) create mode 100644 oauth2_provider/templates/oauth2_provider/check_session_iframe.html create mode 100644 tests/app/rp/.env.example create mode 100644 tests/app/rp/docker-compose.yml create mode 100644 tests/app/rp/src/lib/components/SessionMonitor.svelte create mode 100644 tests/app/rp/src/routes/session/+page.svelte create mode 100644 tests/test_session_management.py diff --git a/AUTHORS b/AUTHORS index 4ebe787cd..cd249c8b0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -103,6 +103,7 @@ Peter McDonald Petr Dlouhý pySilver @realsuayip +Raphael Lullis Rodney Richardson Rustem Saiargaliev Rustem Saiargaliev diff --git a/CHANGELOG.md b/CHANGELOG.md index a29772c13..c2f5ef7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Support for Django 5.2 * Support for Python 3.14 (Django >= 5.2.8) -* #1539 Add device authorization grant support - +* Support for OIDC Session Management (https://openid.net/specs/openid-connect-session-1_0.html) +
+

Session Monitoring Active

+ +
+
+
+
+ {#if sessionStatus === 'unchanged'} + Session Active + {:else if sessionStatus === 'changed'} + Session Changed + {:else} + Session Status Unknown + {/if} +
+
+
+ + {#if sessionChanged} +
+

⚠ Session State Change Detected

+

+ Your session state with the OIDC provider has changed at {sessionChangeTime}. + This might mean that you have logged out from the provider in another tab or + browser. +

+
+ +
+
+ {/if} + +
+

Session Information

+ + + + + + + + + + + + + + + + + + + + + + + +
Session State{sessionState || 'Not available'}
Client ID{CLIENT_ID}
Monitoring Status{monitoringActive ? 'Active' : 'Inactive'}
Last Check{lastCheckTime || 'Never'}
Check Session Iframe{checkSessionIframeUrl || 'Not available'}
+
+ + + {#if checkSessionIframeUrl} + + {/if} +
+{/if} + +
+

How OIDC Session Management Works

+
    +
  1. + Check Discovery: The application fetches the OIDC provider's discovery + document to find the check_session_iframe endpoint. +
  2. +
  3. + Load Iframe: A hidden iframe is created pointing to the provider's check_session_iframe + endpoint. +
  4. +
  5. + Periodic Checks: Every 5 seconds, the application sends a message to the + iframe containing the client ID and session state. +
  6. +
  7. + Status Response: The iframe responds with one of three values: +
      +
    • "unchanged" - Session is still active
    • +
    • "changed" - Session has changed (user logged out elsewhere)
    • +
    • "error" - An error occurred
    • +
    +
  8. +
  9. + UI Update: The application updates the UI based on the session status, alerting + the user if their session has changed. +
  10. +
+

+ This implements the OpenID Connect Session Management 1.0 specification. +

+
+ + diff --git a/tests/app/rp/src/routes/+layout.svelte b/tests/app/rp/src/routes/+layout.svelte index b631c5162..c0f7ff914 100644 --- a/tests/app/rp/src/routes/+layout.svelte +++ b/tests/app/rp/src/routes/+layout.svelte @@ -1,6 +1,12 @@ -{#if browser} - -
-
- Login - Logout - refreshToken -
-
-
-
- - - - - - - - - - - -
isLoadingisAuthenticatedauthError
{$isLoading}{$isAuthenticated}{$authError || 'None'}
-
-
-
-
- - - - - - - - - -
storevalue
userInfo
{JSON.stringify($userInfo, null, 2) || ''}
accessToken{$accessToken}
idToken{$idToken}
-
-
-
-
- -
-
-
-{/if} +
+
+ Login + Logout + refreshToken +
+
+
+
+ + + + + + + + + + + +
isLoadingisAuthenticatedauthError
{$isLoading}{$isAuthenticated}{$authError || 'None'}
+
+
+
+
+ + + + + + + + + +
storevalue
userInfo
{JSON.stringify($userInfo, null, 2) || ''}
accessToken{$accessToken}
idToken{$idToken}
+
+
+
+
+ +
+
diff --git a/tests/app/rp/src/routes/device/+page.svelte b/tests/app/rp/src/routes/device/+page.svelte index cfa9555e7..70b7c2563 100644 --- a/tests/app/rp/src/routes/device/+page.svelte +++ b/tests/app/rp/src/routes/device/+page.svelte @@ -1,10 +1,11 @@ + + + OIDC Session Management Test + + + diff --git a/tests/app/rp/vite.config.ts b/tests/app/rp/vite.config.ts index bbf8c7da4..ee1ffe133 100644 --- a/tests/app/rp/vite.config.ts +++ b/tests/app/rp/vite.config.ts @@ -2,5 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()] + plugins: [sveltekit()], + server: { + allowedHosts: [process.env.PUBLIC_HOST ?? 'localhost'] + } }); diff --git a/tests/presets.py b/tests/presets.py index 4538c64eb..6a1a2974e 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -37,6 +37,10 @@ OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED["OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS"] = False OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS = deepcopy(OIDC_SETTINGS_RP_LOGOUT) OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS["OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS"] = False +OIDC_SETTINGS_SESSION_MANAGEMENT = deepcopy(OIDC_SETTINGS_RW) +OIDC_SETTINGS_SESSION_MANAGEMENT["OIDC_SESSION_MANAGEMENT_ENABLED"] = True +OIDC_SETTINGS_SESSION_MANAGEMENT["OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY"] = "oidc-session-test-key" + REST_FRAMEWORK_SCOPES = { "SCOPES": { "read": "Read scope", diff --git a/tests/test_django_checks.py b/tests/test_django_checks.py index 77025b115..baa1d1f17 100644 --- a/tests/test_django_checks.py +++ b/tests/test_django_checks.py @@ -1,8 +1,15 @@ +from copy import deepcopy + from django.core.management import call_command from django.core.management.base import SystemCheckError from django.test import override_settings from .common_testing import OAuth2ProviderTestCase as TestCase +from .presets import OIDC_SETTINGS_SESSION_MANAGEMENT + + +MISSING_DEFAULT_SESSION_KEY = deepcopy(OIDC_SETTINGS_SESSION_MANAGEMENT) +MISSING_DEFAULT_SESSION_KEY["OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY"] = None class DjangoChecksTestCase(TestCase): @@ -18,3 +25,11 @@ def test_checks_fail_when_router_crosses_databases(self): message = "The token models are expected to be stored in the same database." with self.assertRaisesMessage(SystemCheckError, message): call_command("check") + + @override_settings(OAUTH2_PROVIDER=MISSING_DEFAULT_SESSION_KEY) + def test_checks_fail_when_default_session_key_is_missing(self): + message = ( + "OIDC Session management is enabled, OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY is required." + ) + with self.assertRaisesMessage(SystemCheckError, message): + call_command("check") diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 3fb292060..c412cd2a5 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -228,7 +228,7 @@ def test_load_application_uses_cached_when_request_has_valid_client_matching_cli self.assertIs(self.request.client, self.application) def test_load_application_succeeds_when_request_has_invalid_client_valid_client_id(self): - self.request.client = 'invalid_client' + self.request.client = "invalid_client" application = self.validator._load_application("client_id", self.request) self.assertEqual(application, self.application) self.assertEqual(self.request.client, self.application) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 65197cbd1..f5ddcd62f 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -1,5 +1,5 @@ import pytest -from django.contrib.auth import get_user +from django.contrib.auth import get_user, get_user_model from django.contrib.auth.models import AnonymousUser from django.test import RequestFactory from django.urls import reverse @@ -12,10 +12,20 @@ InvalidOIDCClientError, InvalidOIDCRedirectURIError, ) -from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_id_token_model, + get_refresh_token_model, +) from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views.oidc import RPInitiatedLogoutView, _load_id_token, _validate_claims +from oauth2_provider.views.oidc import ( + RPInitiatedLogoutView, + SessionIFrameView, + _load_id_token, + _validate_claims, +) from . import presets from .common_testing import OAuth2ProviderTestCase as TestCase @@ -111,6 +121,13 @@ def test_get_connect_discovery_info_with_rp_logout(self): self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True self.expect_json_response_with_rp_logout(self.oauth2_settings.OIDC_ISS_ENDPOINT) + def test_get_session_manangement_iframe_endpoint(self): + self.oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED = True + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + response_data = response.json() + self.assertIn("check_session_iframe", response_data.keys()) + def test_get_connect_discovery_info_without_issuer_url(self): self.oauth2_settings.OIDC_ISS_ENDPOINT = None self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None @@ -132,7 +149,10 @@ def test_get_connect_discovery_info_without_issuer_url(self): ], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + ], "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], } @@ -206,6 +226,53 @@ def test_get_jwks_info_multiple_rsa_keys(self): assert response.json() == expected_response +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_SESSION_MANAGEMENT) +class TestSessionManagement(TestCase): + def setUp(self): + User = get_user_model() + Application = get_application_model() + + self.user = User.objects.create_user("test_user", "test@example.com", "123456") + self.developer = User.objects.create_user("dev_user", "dev@example.com", "123456") + + self.application = Application.objects.create( + name="Test Application", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), + user=self.developer, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret="1234567890qwertyuiop", + ) + + def test_session_state_is_present_in_authorization(self): + self.client.login(username="test_user", password="123456") + response = self.client.post( + reverse("oauth2_provider:authorize"), + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "allow": True, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("session_state", response["Location"]) + + def test_cookie_name_is_included_in_iframe_endpoint(self): + request = RequestFactory().get(reverse("oauth2_provider:session-iframe")) + request.user = self.user + view = SessionIFrameView() + view.setup(request) + context = view.get_context_data() + self.assertIn("cookie_name", context) + self.assertEqual(context["cookie_name"], "oidc_ua_agent_state") + + def mock_request(): """ Dummy request with an AnonymousUser attached. diff --git a/tests/test_session_management.py b/tests/test_session_management.py new file mode 100644 index 000000000..5b956047a --- /dev/null +++ b/tests/test_session_management.py @@ -0,0 +1,51 @@ +from copy import deepcopy +from http.cookies import SimpleCookie + +import pytest +from django.contrib.auth import get_user_model +from django.test.utils import modify_settings + +from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase + + +PRESET_OIDC_MIDDLEWARE = deepcopy(presets.OIDC_SETTINGS_SESSION_MANAGEMENT) +PRESET_OIDC_MIDDLEWARE["OIDC_SESSION_MANAGEMENT_COOKIE_NAME"] = "oidc-session-test" + +User = get_user_model() + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(PRESET_OIDC_MIDDLEWARE) +@modify_settings(MIDDLEWARE={"append": "oauth2_provider.middleware.OIDCSessionManagementMiddleware"}) +class TestOIDCSessionManagementMiddleware(TestCase): + def setUp(self): + User.objects.create_user("test_user", "test@example.com", "123456") + + def test_response_is_intact_if_session_management_is_disabled(self): + self.oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED = False + response = self.client.get("/a-resource") + self.assertFalse("oidc-session-test" in response.cookies.keys()) + + def test_session_cookie_is_set_for_logged_users(self): + self.client.login(username="test_user", password="123456") + response = self.client.get("/a-resource") + self.assertTrue(isinstance(response.cookies, SimpleCookie)) + self.assertTrue("oidc-session-test" in response.cookies.keys()) + self.assertNotEqual(response.cookies["oidc-session-test"].value, "") + + def test_session_cookie_is_cleared_for_anonymous_users(self): + response = self.client.get("/a-resource") + self.assertTrue(isinstance(response.cookies, SimpleCookie)) + self.assertTrue("oidc-session-test" in response.cookies.keys()) + self.assertEqual(response.cookies["oidc-session-test"].value, "") + + def test_session_cookie_is_not_set_after_logging_out(self): + self.client.login(username="test_user", password="123456") + self.client.get("/a-resource") + self.client.logout() + + response = self.client.get("/another-resource") + self.assertTrue(isinstance(response.cookies, SimpleCookie)) + self.assertTrue("oidc-session-test" in response.cookies.keys()) + self.assertEqual(response.cookies["oidc-session-test"].value, "")