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.
+
+
+
+ Acknowledge & Continue Monitoring
+
+
+
+ {/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
+
+
+ Check Discovery: The application fetches the OIDC provider's discovery
+ document to find the check_session_iframe endpoint.
+
+
+ Load Iframe: A hidden iframe is created pointing to the provider's check_session_iframe
+ endpoint.
+
+
+ Periodic Checks: Every 5 seconds, the application sends a message to the
+ iframe containing the client ID and session state.
+
+
+ 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
+
+
+
+ UI Update: The application updates the UI based on the session status, alerting
+ the user if their session has changed.
+
+
+
+ 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
-
-
-
-
-
-
- isLoading isAuthenticated authError
-
-
-
- {$isLoading}
- {$isAuthenticated}
- {$authError || 'None'}
-
-
-
-
-
-
-
-
-
- store value
-
-
- userInfo {JSON.stringify($userInfo, null, 2) || ''}
- accessToken {$accessToken}
- idToken {$idToken}
-
-
-
-
-
-
-{/if}
+
+
+ Login
+ Logout
+ refreshToken
+
+
+
+
+
+
+ isLoading isAuthenticated authError
+
+
+
+ {$isLoading}
+ {$isAuthenticated}
+ {$authError || 'None'}
+
+
+
+
+
+
+
+
+
+ store value
+
+
+ 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, "")