From 8c3d5f12690ae8877768f8f072ae105d698f4329 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Tue, 7 Apr 2026 03:46:11 -0500 Subject: [PATCH] providers/oauth: post_logout_redirect_uri support (#20011) * oauth2/providers: add post logout redirect uri to providers * properly handle post_logout_redirect_uri and frontchannel message to rp * add backchannel support * move logout url logic * hanlde forbidden_uri_schemes on post_logout_redirect_uri * merge post_logout with redirect_uri --------- Signed-off-by: Connor Peshek Co-authored-by: Jens L. --- authentik/common/oauth/constants.py | 6 + authentik/flows/stage.py | 18 ++ authentik/providers/oauth2/api/providers.py | 4 + authentik/providers/oauth2/models.py | 30 +- authentik/providers/oauth2/signals.py | 54 ++-- .../oauth2/tests/test_end_session.py | 263 ++++++++++++++++++ authentik/providers/oauth2/utils.py | 31 ++- authentik/providers/oauth2/views/authorize.py | 13 +- .../providers/oauth2/views/end_session.py | 141 +++++++++- authentik/providers/oauth2/views/provider.py | 2 + authentik/providers/oauth2/views/token.py | 2 +- .../providers/saml/tests/test_idp_logout.py | 13 +- blueprints/schema.json | 8 + packages/client-go/model_redirect_uri.go | 45 ++- .../client-go/model_redirect_uri_request.go | 45 ++- .../client-go/model_redirect_uri_type_enum.go | 111 ++++++++ packages/client-rust/src/models/mod.rs | 2 + .../client-rust/src/models/redirect_uri.rs | 8 +- .../src/models/redirect_uri_request.rs | 8 +- .../src/models/redirect_uri_type_enum.rs | 35 +++ packages/client-ts/src/models/RedirectURI.ts | 13 + .../src/models/RedirectURIRequest.ts | 13 + .../src/models/RedirectUriTypeEnum.ts | 57 ++++ packages/client-ts/src/models/index.ts | 1 + schema.yml | 13 + .../steps/SubmitStepOverviewRenderers.ts | 6 +- .../oauth2/OAuth2ProviderFormForm.ts | 11 +- .../oauth2/OAuth2ProviderRedirectURI.ts | 22 +- 28 files changed, 905 insertions(+), 70 deletions(-) create mode 100644 authentik/providers/oauth2/tests/test_end_session.py create mode 100644 packages/client-go/model_redirect_uri_type_enum.go create mode 100644 packages/client-rust/src/models/redirect_uri_type_enum.rs create mode 100644 packages/client-ts/src/models/RedirectUriTypeEnum.ts diff --git a/authentik/common/oauth/constants.py b/authentik/common/oauth/constants.py index cc4d5309d3..a375bbd072 100644 --- a/authentik/common/oauth/constants.py +++ b/authentik/common/oauth/constants.py @@ -21,6 +21,9 @@ PROMPT_CONSENT = "consent" PROMPT_LOGIN = "login" PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS = "goauthentik.io/providers/oauth2/iframe_sessions" +PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI = "goauthentik.io/providers/oauth2/post_logout_redirect_uri" + +OAUTH2_BINDING = "redirect" SCOPE_OPENID = "openid" SCOPE_OPENID_PROFILE = "profile" @@ -37,6 +40,9 @@ TOKEN_TYPE = "Bearer" # nosec SCOPE_AUTHENTIK_API = "goauthentik.io/api" +# URI schemes that are forbidden for redirect URIs +FORBIDDEN_URI_SCHEMES = {"javascript", "data", "vbscript"} + # Read/write full user (including email) SCOPE_GITHUB_USER = "user" # Read user (without email) diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index 634fd79991..87ad0eaaf8 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -15,6 +15,7 @@ from rest_framework.request import Request from sentry_sdk import start_span from structlog.stdlib import BoundLogger, get_logger +from authentik.common.oauth.constants import PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI from authentik.core.models import Application, User from authentik.flows.challenge import ( AccessDeniedChallenge, @@ -300,7 +301,24 @@ class SessionEndStage(ChallengeStageView): that the user is likely to take after signing out of a provider.""" def get_challenge(self, *args, **kwargs) -> Challenge: + # Check for OIDC post_logout_redirect_uri in context + post_logout_redirect_uri = self.executor.plan.context.get( + PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI + ) + + if post_logout_redirect_uri: + self.logger.debug( + "SessionEndStage redirecting to post_logout_redirect_uri", + redirect_url=post_logout_redirect_uri, + ) + return RedirectChallenge( + data={ + "to": post_logout_redirect_uri, + }, + ) + if not self.request.user.is_authenticated: + # User is logged out with no redirect URI - go to default return RedirectChallenge( data={ "to": reverse("authentik_core:root-redirect"), diff --git a/authentik/providers/oauth2/api/providers.py b/authentik/providers/oauth2/api/providers.py index 89ca25509e..3a484ea9ce 100644 --- a/authentik/providers/oauth2/api/providers.py +++ b/authentik/providers/oauth2/api/providers.py @@ -27,6 +27,7 @@ from authentik.providers.oauth2.models import ( AccessToken, OAuth2Provider, RedirectURIMatchingMode, + RedirectURIType, ScopeMapping, ) from authentik.rbac.decorators import permission_required @@ -37,6 +38,9 @@ class RedirectURISerializer(PassiveSerializer): matching_mode = ChoiceField(choices=RedirectURIMatchingMode.choices) url = CharField() + redirect_uri_type = ChoiceField( + choices=RedirectURIType.choices, default=RedirectURIType.AUTHORIZATION, required=False + ) class OAuth2ProviderSerializer(ProviderSerializer): diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 07aaf284f2..3a8e72a882 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -97,6 +97,11 @@ class RedirectURIMatchingMode(models.TextChoices): REGEX = "regex", _("Regular Expression URL matching") +class RedirectURIType(models.TextChoices): + AUTHORIZATION = "authorization", _("Authorization") + LOGOUT = "logout", _("Logout") + + class OAuth2LogoutMethod(models.TextChoices): """OAuth2/OIDC Logout methods""" @@ -110,6 +115,7 @@ class RedirectURI: matching_mode: RedirectURIMatchingMode url: str + redirect_uri_type: RedirectURIType = RedirectURIType.AUTHORIZATION class ResponseTypes(models.TextChoices): @@ -220,7 +226,6 @@ class OAuth2Provider(WebfingerProvider, Provider): "Frontchannel uses iframes in your browser" ), ) - include_claims_in_id_token = models.BooleanField( default=True, verbose_name=_("Include claims in id_token"), @@ -343,7 +348,12 @@ class OAuth2Provider(WebfingerProvider, Provider): from_dict( RedirectURI, entry, - config=Config(type_hooks={RedirectURIMatchingMode: RedirectURIMatchingMode}), + config=Config( + type_hooks={ + RedirectURIMatchingMode: RedirectURIMatchingMode, + RedirectURIType: RedirectURIType, + } + ), ) ) return uris @@ -355,10 +365,24 @@ class OAuth2Provider(WebfingerProvider, Provider): cleansed.append(asdict(entry)) self._redirect_uris = cleansed + @property + def authorization_redirect_uris(self) -> list[RedirectURI]: + return [ + uri + for uri in self.redirect_uris + if uri.redirect_uri_type == RedirectURIType.AUTHORIZATION + ] + + @property + def post_logout_redirect_uris(self) -> list[RedirectURI]: + return [ + uri for uri in self.redirect_uris if uri.redirect_uri_type == RedirectURIType.LOGOUT + ] + @property def launch_url(self) -> str | None: """Guess launch_url based on first redirect_uri""" - redirects = self.redirect_uris + redirects = self.authorization_redirect_uris if len(redirects) < 1: return None main_url = redirects[0].url diff --git a/authentik/providers/oauth2/signals.py b/authentik/providers/oauth2/signals.py index 48047ccd77..5a04100d3b 100644 --- a/authentik/providers/oauth2/signals.py +++ b/authentik/providers/oauth2/signals.py @@ -1,13 +1,13 @@ -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse - from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from structlog.stdlib import get_logger -from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS +from authentik.common.oauth.constants import ( + OAUTH2_BINDING, + PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS, +) from authentik.core.models import AuthenticatedSession, User from authentik.flows.models import in_memory_stage -from authentik.outposts.tasks import hash_session_key from authentik.providers.iframe_logout import IframeLogoutStageView from authentik.providers.oauth2.models import ( AccessToken, @@ -16,6 +16,7 @@ from authentik.providers.oauth2.models import ( RefreshToken, ) from authentik.providers.oauth2.tasks import backchannel_logout_notification_dispatch +from authentik.providers.oauth2.utils import build_frontchannel_logout_url from authentik.stages.user_logout.models import UserLogoutStage from authentik.stages.user_logout.stage import flow_pre_user_logout @@ -51,43 +52,22 @@ def handle_flow_pre_user_logout(sender, request, user, executor, **kwargs): LOGGER.debug("No sessions requiring IFrame frontchannel logout") return + session_key = auth_session.session.session_key if auth_session.session else None oidc_sessions = [] for token in oidc_access_tokens: - # Parse the logout URI and add query parameters - parsed_url = urlparse(token.provider.logout_uri) - - query_params = {} - query_params["iss"] = token.provider.get_issuer(request) - if auth_session.session: - query_params["sid"] = hash_session_key(auth_session.session.session_key) - - # Combine existing query params with new ones - if parsed_url.query: - existing_params = parse_qs(parsed_url.query, keep_blank_values=True) - for key, value in existing_params.items(): - if key not in query_params: - query_params[key] = value[0] if len(value) == 1 else value - - # Build the final URL with query parameters - logout_url = urlunparse( - ( - parsed_url.scheme, - parsed_url.netloc, - parsed_url.path, - parsed_url.params, - urlencode(query_params), - parsed_url.fragment, + logout_url = build_frontchannel_logout_url(token.provider, request, session_key) + if logout_url: + oidc_sessions.append( + { + "url": logout_url, + "provider_name": token.provider.name, + "binding": OAUTH2_BINDING, + "provider_type": ( + f"{token.provider._meta.app_label}.{token.provider._meta.model_name}" + ), + } ) - ) - - logout_data = { - "url": logout_url, - "provider_name": token.provider.name, - "binding": "redirect", - "provider_type": "oidc", - } - oidc_sessions.append(logout_data) if oidc_sessions: executor.plan.context[PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS] = oidc_sessions diff --git a/authentik/providers/oauth2/tests/test_end_session.py b/authentik/providers/oauth2/tests/test_end_session.py new file mode 100644 index 0000000000..5c1bb32595 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_end_session.py @@ -0,0 +1,263 @@ +"""Test OAuth2 End Session (RP-Initiated Logout) implementation""" + +from django.test import RequestFactory +from django.urls import reverse + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow +from authentik.lib.generators import generate_id +from authentik.providers.oauth2.models import ( + OAuth2Provider, + RedirectURI, + RedirectURIMatchingMode, + RedirectURIType, +) +from authentik.providers.oauth2.tests.utils import OAuthTestCase +from authentik.providers.oauth2.views.end_session import EndSessionView + + +class TestEndSessionView(OAuthTestCase): + """Test EndSessionView validation""" + + def setUp(self) -> None: + super().setUp() + self.user = create_test_admin_user() + self.invalidation_flow = create_test_flow() + self.app = Application.objects.create(name=generate_id(), slug="test-app") + self.provider = OAuth2Provider.objects.create( + name=generate_id(), + authorization_flow=create_test_flow(), + invalidation_flow=self.invalidation_flow, + redirect_uris=[ + RedirectURI( + RedirectURIMatchingMode.STRICT, + "http://testserver/callback", + RedirectURIType.AUTHORIZATION, + ), + RedirectURI( + RedirectURIMatchingMode.STRICT, + "http://testserver/logout", + RedirectURIType.LOGOUT, + ), + RedirectURI( + RedirectURIMatchingMode.REGEX, + r"https://.*\.example\.com/logout", + RedirectURIType.LOGOUT, + ), + ], + ) + self.app.provider = self.provider + self.app.save() + # Ensure brand has an invalidation flow + self.brand = create_test_brand() + self.brand.flow_invalidation = self.invalidation_flow + self.brand.save() + + def test_post_logout_redirect_uri_strict_match(self): + """Test strict URI matching redirects to flow""" + self.client.force_login(self.user) + response = self.client.get( + reverse( + "authentik_providers_oauth2:end-session", + kwargs={"application_slug": self.app.slug}, + ), + {"post_logout_redirect_uri": "http://testserver/logout"}, + HTTP_HOST=self.brand.domain, + ) + # Should redirect to the invalidation flow + self.assertEqual(response.status_code, 302) + self.assertIn(self.invalidation_flow.slug, response.url) + + def test_post_logout_redirect_uri_strict_no_match(self): + """Test strict URI not matching still proceeds with flow (no redirect URI in context)""" + self.client.force_login(self.user) + invalid_uri = "http://testserver/other" + response = self.client.get( + reverse( + "authentik_providers_oauth2:end-session", + kwargs={"application_slug": self.app.slug}, + ), + {"post_logout_redirect_uri": invalid_uri}, + HTTP_HOST=self.brand.domain, + ) + # Should still redirect to flow, but invalid URI should not be in response + self.assertEqual(response.status_code, 302) + self.assertNotIn(invalid_uri, response.url) + + def test_post_logout_redirect_uri_regex_match(self): + """Test regex URI matching redirects to flow""" + self.client.force_login(self.user) + response = self.client.get( + reverse( + "authentik_providers_oauth2:end-session", + kwargs={"application_slug": self.app.slug}, + ), + {"post_logout_redirect_uri": "https://app.example.com/logout"}, + HTTP_HOST=self.brand.domain, + ) + # Should redirect to the invalidation flow + self.assertEqual(response.status_code, 302) + self.assertIn(self.invalidation_flow.slug, response.url) + + def test_post_logout_redirect_uri_regex_no_match(self): + """Test regex URI not matching""" + self.client.force_login(self.user) + invalid_uri = "https://malicious.com/logout" + response = self.client.get( + reverse( + "authentik_providers_oauth2:end-session", + kwargs={"application_slug": self.app.slug}, + ), + {"post_logout_redirect_uri": invalid_uri}, + HTTP_HOST=self.brand.domain, + ) + # Should still proceed to flow, but invalid URI should not be in response + self.assertEqual(response.status_code, 302) + self.assertNotIn(invalid_uri, response.url) + + def test_state_parameter_appended_to_uri(self): + """Test state parameter is appended to validated redirect URI""" + factory = RequestFactory() + request = factory.get( + "/end-session/", + { + "post_logout_redirect_uri": "http://testserver/logout", + "state": "test-state-123", + }, + ) + request.user = self.user + request.brand = self.brand + + view = EndSessionView() + view.request = request + view.kwargs = {"application_slug": self.app.slug} + view.resolve_provider_application() + + self.assertIn("state=test-state-123", view.post_logout_redirect_uri) + + def test_post_method(self): + """Test POST requests work same as GET""" + self.client.force_login(self.user) + response = self.client.post( + reverse( + "authentik_providers_oauth2:end-session", + kwargs={"application_slug": self.app.slug}, + ), + { + "post_logout_redirect_uri": "http://testserver/logout", + "state": "xyz789", + }, + HTTP_HOST=self.brand.domain, + ) + self.assertEqual(response.status_code, 302) + + +class TestEndSessionAPI(OAuthTestCase): + """Test End Session API functionality""" + + def setUp(self) -> None: + super().setUp() + self.user = create_test_admin_user() + self.client.force_login(self.user) + + def test_post_logout_redirect_uris_create(self): + """Test creating provider with post_logout redirect_uris""" + response = self.client.post( + reverse("authentik_api:oauth2provider-list"), + data={ + "name": generate_id(), + "authorization_flow": create_test_flow().pk, + "invalidation_flow": create_test_flow().pk, + "redirect_uris": [ + { + "matching_mode": "strict", + "url": "http://testserver/callback", + "redirect_uri_type": "authorization", + }, + { + "matching_mode": "strict", + "url": "http://testserver/logout", + "redirect_uri_type": "logout", + }, + { + "matching_mode": "regex", + "url": "https://.*\\.example\\.com/logout", + "redirect_uri_type": "logout", + }, + ], + }, + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + provider_data = response.json() + post_logout_uris = [ + u for u in provider_data["redirect_uris"] if u["redirect_uri_type"] == "logout" + ] + self.assertEqual(len(post_logout_uris), 2) + + def test_post_logout_redirect_uris_invalid_regex(self): + """Test that invalid regex patterns are rejected""" + response = self.client.post( + reverse("authentik_api:oauth2provider-list"), + data={ + "name": generate_id(), + "authorization_flow": create_test_flow().pk, + "invalidation_flow": create_test_flow().pk, + "redirect_uris": [ + { + "matching_mode": "strict", + "url": "http://testserver/callback", + "redirect_uri_type": "authorization", + }, + { + "matching_mode": "regex", + "url": "**invalid**", + "redirect_uri_type": "logout", + }, + ], + }, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("redirect_uris", response.json()) + + def test_post_logout_redirect_uris_update(self): + """Test updating redirect_uris with logout type""" + # First create a provider + provider = OAuth2Provider.objects.create( + name=generate_id(), + authorization_flow=create_test_flow(), + redirect_uris=[ + RedirectURI( + RedirectURIMatchingMode.STRICT, + "http://testserver/callback", + RedirectURIType.AUTHORIZATION, + ), + ], + ) + + # Update with post_logout redirect URIs + response = self.client.patch( + reverse("authentik_api:oauth2provider-detail", kwargs={"pk": provider.pk}), + data={ + "redirect_uris": [ + { + "matching_mode": "strict", + "url": "http://testserver/callback", + "redirect_uri_type": "authorization", + }, + { + "matching_mode": "strict", + "url": "http://testserver/logout", + "redirect_uri_type": "logout", + }, + ], + }, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + + # Verify the update + provider.refresh_from_db() + self.assertEqual(len(provider.post_logout_redirect_uris), 1) + self.assertEqual(provider.post_logout_redirect_uris[0].url, "http://testserver/logout") diff --git a/authentik/providers/oauth2/utils.py b/authentik/providers/oauth2/utils.py index e7e820430a..b30f8f8499 100644 --- a/authentik/providers/oauth2/utils.py +++ b/authentik/providers/oauth2/utils.py @@ -7,7 +7,7 @@ from binascii import Error from hashlib import sha256 from hmac import compare_digest from typing import Any -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from django.http import HttpRequest, HttpResponse, JsonResponse from django.http.response import HttpResponseRedirect @@ -267,3 +267,32 @@ def create_logout_token( payload["sid"] = hash_session_key(session_key) # Encode the token return provider.encode(payload, jwt_type="logout+jwt") + + +def build_frontchannel_logout_url( + provider: OAuth2Provider, + request: HttpRequest, + session_key: str | None = None, +) -> str | None: + """Build frontchannel logout URL with iss and sid parameters. + + Returns None if provider doesn't have a logout_uri configured. + """ + if not provider.logout_uri: + return None + + parsed_url = urlparse(provider.logout_uri) + + query_params = {"iss": provider.get_issuer(request)} + if session_key: + query_params["sid"] = hash_session_key(session_key) + + # Preserve existing query params + if parsed_url.query: + existing_params = parse_qs(parsed_url.query, keep_blank_values=True) + for key, value in existing_params.items(): + if key not in query_params: + query_params[key] = value[0] if len(value) == 1 else value + + parsed_url = parsed_url._replace(query=urlencode(query_params)) + return urlunparse(parsed_url) diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index 3a8aee4820..7793abfc43 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -18,6 +18,7 @@ from django.utils.translation import gettext as _ from structlog.stdlib import get_logger from authentik.common.oauth.constants import ( + FORBIDDEN_URI_SCHEMES, PKCE_METHOD_PLAIN, PKCE_METHOD_S256, PROMPT_CONSENT, @@ -60,6 +61,7 @@ from authentik.providers.oauth2.models import ( OAuth2Provider, RedirectURI, RedirectURIMatchingMode, + RedirectURIType, ResponseMode, ResponseTypes, ScopeMapping, @@ -78,7 +80,6 @@ PLAN_CONTEXT_PARAMS = "goauthentik.io/providers/oauth2/params" SESSION_KEY_LAST_LOGIN_UID = "authentik/providers/oauth2/last_login_uid" ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN} -FORBIDDEN_URI_SCHEMES = {"javascript", "data", "vbscript"} @dataclass(slots=True) @@ -191,7 +192,7 @@ class OAuthAuthorizationParams: def check_redirect_uri(self): """Redirect URI validation.""" - allowed_redirect_urls = self.provider.redirect_uris + allowed_redirect_urls = self.provider.authorization_redirect_uris if not self.redirect_uri: LOGGER.warning("Missing redirect uri.") raise RedirectUriError("", allowed_redirect_urls).with_cause("redirect_uri_missing") @@ -199,10 +200,14 @@ class OAuthAuthorizationParams: if len(allowed_redirect_urls) < 1: LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri) self.provider.redirect_uris = [ - RedirectURI(RedirectURIMatchingMode.STRICT, self.redirect_uri) + RedirectURI( + RedirectURIMatchingMode.STRICT, + self.redirect_uri, + RedirectURIType.AUTHORIZATION, + ) ] self.provider.save() - allowed_redirect_urls = self.provider.redirect_uris + allowed_redirect_urls = self.provider.authorization_redirect_uris match_found = False for allowed in allowed_redirect_urls: diff --git a/authentik/providers/oauth2/views/end_session.py b/authentik/providers/oauth2/views/end_session.py index c793565310..a24d183e16 100644 --- a/authentik/providers/oauth2/views/end_session.py +++ b/authentik/providers/oauth2/views/end_session.py @@ -1,20 +1,42 @@ """oauth2 provider end_session Views""" +from re import fullmatch +from urllib.parse import quote, urlparse + from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 -from authentik.core.models import Application +from authentik.common.oauth.constants import ( + FORBIDDEN_URI_SCHEMES, + OAUTH2_BINDING, + PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS, + PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI, +) +from authentik.core.models import Application, AuthenticatedSession from authentik.flows.models import Flow, in_memory_stage -from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner +from authentik.flows.planner import ( + PLAN_CONTEXT_APPLICATION, + FlowPlanner, +) from authentik.flows.stage import SessionEndStage from authentik.flows.views.executor import SESSION_KEY_PLAN -from authentik.policies.views import PolicyAccessView +from authentik.lib.views import bad_request_message +from authentik.policies.views import PolicyAccessView, RequestValidationError +from authentik.providers.iframe_logout import IframeLogoutStageView +from authentik.providers.oauth2.models import ( + AccessToken, + OAuth2LogoutMethod, + RedirectURIMatchingMode, +) +from authentik.providers.oauth2.tasks import send_backchannel_logout_request +from authentik.providers.oauth2.utils import build_frontchannel_logout_url class EndSessionView(PolicyAccessView): - """Redirect to application's provider's invalidation flow""" + """OIDC RP-Initiated Logout endpoint""" flow: Flow + post_logout_redirect_uri: str | None def resolve_provider_application(self): self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"]) @@ -25,6 +47,38 @@ class EndSessionView(PolicyAccessView): if not self.flow: raise Http404 + # Parse end session parameters + query_dict = self.request.POST if self.request.method == "POST" else self.request.GET + state = query_dict.get("state") + request_redirect_uri = query_dict.get("post_logout_redirect_uri") + self.post_logout_redirect_uri = None + + # Validate post_logout_redirect_uri against registered URIs + if request_redirect_uri: + if urlparse(request_redirect_uri).scheme in FORBIDDEN_URI_SCHEMES: + raise RequestValidationError( + bad_request_message( + self.request, + "Forbidden URI scheme in post_logout_redirect_uri", + ) + ) + for allowed in self.provider.post_logout_redirect_uris: + if allowed.matching_mode == RedirectURIMatchingMode.STRICT: + if request_redirect_uri == allowed.url: + self.post_logout_redirect_uri = request_redirect_uri + break + elif allowed.matching_mode == RedirectURIMatchingMode.REGEX: + if fullmatch(allowed.url, request_redirect_uri): + self.post_logout_redirect_uri = request_redirect_uri + break + + # Append state to the redirect URI if both are present + if self.post_logout_redirect_uri and state: + separator = "&" if "?" in self.post_logout_redirect_uri else "?" + self.post_logout_redirect_uri = ( + f"{self.post_logout_redirect_uri}{separator}state={quote(state, safe='')}" + ) + # If IFrame provider logout happens when a saml provider has redirect # logout enabled, the flow won't make it back without this dispatch def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: @@ -44,11 +98,80 @@ class EndSessionView(PolicyAccessView): """Dispatch the flow planner for the invalidation flow""" planner = FlowPlanner(self.flow) planner.allow_empty_flows = True - plan = planner.plan( - request, - { - PLAN_CONTEXT_APPLICATION: self.application, - }, + + # Build flow context with logout parameters + context = { + PLAN_CONTEXT_APPLICATION: self.application, + } + + # Get session info for logout notifications and token invalidation + auth_session = AuthenticatedSession.from_request(request, request.user) + + # Add validated redirect URI (with state appended) to context if available + if self.post_logout_redirect_uri: + context[PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI] = self.post_logout_redirect_uri + # Invalidate tokens for this provider/session (RP-initiated logout: + # user stays logged into authentik, only this provider's tokens are revoked) + if request.user.is_authenticated and auth_session: + AccessToken.objects.filter( + user=request.user, + provider=self.provider, + session=auth_session, + ).delete() + session_key = ( + auth_session.session.session_key if auth_session and auth_session.session else None ) + + # Handle frontchannel logout + frontchannel_logout_url = None + if self.provider.logout_method == OAuth2LogoutMethod.FRONTCHANNEL: + frontchannel_logout_url = build_frontchannel_logout_url( + self.provider, request, session_key + ) + + # Handle backchannel logout + if ( + self.provider.logout_method == OAuth2LogoutMethod.BACKCHANNEL + and self.provider.logout_uri + ): + # Find access token to get iss and sub for the logout token + access_token = AccessToken.objects.filter( + user=request.user, + provider=self.provider, + session=auth_session, + ).first() + if access_token and access_token.id_token: + send_backchannel_logout_request.send( + self.provider.pk, + access_token.id_token.iss, + access_token.id_token.sub, + session_key, + ) + # Delete the token to prevent duplicate backchannel logout + # when UserLogoutStage triggers the session deletion signal + access_token.delete() + + if frontchannel_logout_url: + context[PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS] = [ + { + "url": frontchannel_logout_url, + "provider_name": self.provider.name, + "binding": OAUTH2_BINDING, + "provider_type": ( + f"{self.provider._meta.app_label}.{self.provider._meta.model_name}" + ), + } + ] + + plan = planner.plan(request, context) + + # Inject iframe logout stage if frontchannel logout is configured + if frontchannel_logout_url: + plan.insert_stage(in_memory_stage(IframeLogoutStageView)) + plan.append_stage(in_memory_stage(SessionEndStage)) return plan.to_redirect(self.request, self.flow) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Handle POST requests for logout (same as GET per OIDC spec)""" + return self.get(request, *args, **kwargs) diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py index b16113a60b..b1009cf6c5 100644 --- a/authentik/providers/oauth2/views/provider.py +++ b/authentik/providers/oauth2/views/provider.py @@ -74,6 +74,8 @@ class ProviderInfoView(View): ), "backchannel_logout_supported": True, "backchannel_logout_session_supported": True, + "frontchannel_logout_supported": True, + "frontchannel_logout_session_supported": True, "response_types_supported": [ ResponseTypes.CODE, ResponseTypes.ID_TOKEN, diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 8a03c266f4..09f8ca70dc 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -241,7 +241,7 @@ class TokenParams: raise TokenError("invalid_grant") def __check_redirect_uri(self, request: HttpRequest): - allowed_redirect_urls = self.provider.redirect_uris + allowed_redirect_urls = self.provider.authorization_redirect_uris # At this point, no provider should have a blank redirect_uri, in case they do # this will check an empty array and raise an error diff --git a/authentik/providers/saml/tests/test_idp_logout.py b/authentik/providers/saml/tests/test_idp_logout.py index 1f49843305..077d680ae9 100644 --- a/authentik/providers/saml/tests/test_idp_logout.py +++ b/authentik/providers/saml/tests/test_idp_logout.py @@ -5,6 +5,10 @@ from unittest.mock import Mock from django.test import RequestFactory, TestCase +from authentik.common.oauth.constants import ( + OAUTH2_BINDING, + PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS, +) from authentik.common.saml.constants import ( RSA_SHA256, SAML_NAME_ID_FORMAT_EMAIL, @@ -17,6 +21,7 @@ from authentik.providers.iframe_logout import ( IframeLogoutChallenge, IframeLogoutStageView, ) +from authentik.providers.oauth2.models import OAuth2Provider from authentik.providers.saml.models import SAMLLogoutMethods, SAMLProvider from authentik.providers.saml.native_logout import ( NativeLogoutChallenge, @@ -295,14 +300,14 @@ class TestIframeLogoutStageView(TestCase): }, ] # OIDC sessions (pre-processed) - from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS - plan.context[PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS] = [ { "url": "https://oidc.example.com/logout?iss=authentik&sid=abc123", "provider_name": "oidc-provider", - "binding": "redirect", - "provider_type": "oidc", + "binding": OAUTH2_BINDING, + "provider_type": ( + f"{OAuth2Provider._meta.app_label}" f".{OAuth2Provider._meta.model_name}" + ), }, ] stage_view = IframeLogoutStageView( diff --git a/blueprints/schema.json b/blueprints/schema.json index a7bd50266f..8b7c62e9c9 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -10019,6 +10019,14 @@ "type": "string", "minLength": 1, "title": "Url" + }, + "redirect_uri_type": { + "type": "string", + "enum": [ + "authorization", + "logout" + ], + "title": "Redirect uri type" } }, "required": [ diff --git a/packages/client-go/model_redirect_uri.go b/packages/client-go/model_redirect_uri.go index fa475d8864..68c4519d70 100644 --- a/packages/client-go/model_redirect_uri.go +++ b/packages/client-go/model_redirect_uri.go @@ -21,8 +21,9 @@ var _ MappedNullable = &RedirectURI{} // RedirectURI A single allowed redirect URI entry type RedirectURI struct { - MatchingMode MatchingModeEnum `json:"matching_mode"` - Url string `json:"url"` + MatchingMode MatchingModeEnum `json:"matching_mode"` + Url string `json:"url"` + RedirectUriType *RedirectUriTypeEnum `json:"redirect_uri_type,omitempty"` AdditionalProperties map[string]interface{} } @@ -36,6 +37,8 @@ func NewRedirectURI(matchingMode MatchingModeEnum, url string) *RedirectURI { this := RedirectURI{} this.MatchingMode = matchingMode this.Url = url + var redirectUriType RedirectUriTypeEnum = REDIRECTURITYPEENUM_AUTHORIZATION + this.RedirectUriType = &redirectUriType return &this } @@ -44,6 +47,8 @@ func NewRedirectURI(matchingMode MatchingModeEnum, url string) *RedirectURI { // but it doesn't guarantee that properties required by API are set func NewRedirectURIWithDefaults() *RedirectURI { this := RedirectURI{} + var redirectUriType RedirectUriTypeEnum = REDIRECTURITYPEENUM_AUTHORIZATION + this.RedirectUriType = &redirectUriType return &this } @@ -95,6 +100,38 @@ func (o *RedirectURI) SetUrl(v string) { o.Url = v } +// GetRedirectUriType returns the RedirectUriType field value if set, zero value otherwise. +func (o *RedirectURI) GetRedirectUriType() RedirectUriTypeEnum { + if o == nil || IsNil(o.RedirectUriType) { + var ret RedirectUriTypeEnum + return ret + } + return *o.RedirectUriType +} + +// GetRedirectUriTypeOk returns a tuple with the RedirectUriType field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RedirectURI) GetRedirectUriTypeOk() (*RedirectUriTypeEnum, bool) { + if o == nil || IsNil(o.RedirectUriType) { + return nil, false + } + return o.RedirectUriType, true +} + +// HasRedirectUriType returns a boolean if a field has been set. +func (o *RedirectURI) HasRedirectUriType() bool { + if o != nil && !IsNil(o.RedirectUriType) { + return true + } + + return false +} + +// SetRedirectUriType gets a reference to the given RedirectUriTypeEnum and assigns it to the RedirectUriType field. +func (o *RedirectURI) SetRedirectUriType(v RedirectUriTypeEnum) { + o.RedirectUriType = &v +} + func (o RedirectURI) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -107,6 +144,9 @@ func (o RedirectURI) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["matching_mode"] = o.MatchingMode toSerialize["url"] = o.Url + if !IsNil(o.RedirectUriType) { + toSerialize["redirect_uri_type"] = o.RedirectUriType + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -153,6 +193,7 @@ func (o *RedirectURI) UnmarshalJSON(data []byte) (err error) { if err = json.Unmarshal(data, &additionalProperties); err == nil { delete(additionalProperties, "matching_mode") delete(additionalProperties, "url") + delete(additionalProperties, "redirect_uri_type") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_redirect_uri_request.go b/packages/client-go/model_redirect_uri_request.go index b67b5b8ae0..11918394c5 100644 --- a/packages/client-go/model_redirect_uri_request.go +++ b/packages/client-go/model_redirect_uri_request.go @@ -21,8 +21,9 @@ var _ MappedNullable = &RedirectURIRequest{} // RedirectURIRequest A single allowed redirect URI entry type RedirectURIRequest struct { - MatchingMode MatchingModeEnum `json:"matching_mode"` - Url string `json:"url"` + MatchingMode MatchingModeEnum `json:"matching_mode"` + Url string `json:"url"` + RedirectUriType *RedirectUriTypeEnum `json:"redirect_uri_type,omitempty"` AdditionalProperties map[string]interface{} } @@ -36,6 +37,8 @@ func NewRedirectURIRequest(matchingMode MatchingModeEnum, url string) *RedirectU this := RedirectURIRequest{} this.MatchingMode = matchingMode this.Url = url + var redirectUriType RedirectUriTypeEnum = REDIRECTURITYPEENUM_AUTHORIZATION + this.RedirectUriType = &redirectUriType return &this } @@ -44,6 +47,8 @@ func NewRedirectURIRequest(matchingMode MatchingModeEnum, url string) *RedirectU // but it doesn't guarantee that properties required by API are set func NewRedirectURIRequestWithDefaults() *RedirectURIRequest { this := RedirectURIRequest{} + var redirectUriType RedirectUriTypeEnum = REDIRECTURITYPEENUM_AUTHORIZATION + this.RedirectUriType = &redirectUriType return &this } @@ -95,6 +100,38 @@ func (o *RedirectURIRequest) SetUrl(v string) { o.Url = v } +// GetRedirectUriType returns the RedirectUriType field value if set, zero value otherwise. +func (o *RedirectURIRequest) GetRedirectUriType() RedirectUriTypeEnum { + if o == nil || IsNil(o.RedirectUriType) { + var ret RedirectUriTypeEnum + return ret + } + return *o.RedirectUriType +} + +// GetRedirectUriTypeOk returns a tuple with the RedirectUriType field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RedirectURIRequest) GetRedirectUriTypeOk() (*RedirectUriTypeEnum, bool) { + if o == nil || IsNil(o.RedirectUriType) { + return nil, false + } + return o.RedirectUriType, true +} + +// HasRedirectUriType returns a boolean if a field has been set. +func (o *RedirectURIRequest) HasRedirectUriType() bool { + if o != nil && !IsNil(o.RedirectUriType) { + return true + } + + return false +} + +// SetRedirectUriType gets a reference to the given RedirectUriTypeEnum and assigns it to the RedirectUriType field. +func (o *RedirectURIRequest) SetRedirectUriType(v RedirectUriTypeEnum) { + o.RedirectUriType = &v +} + func (o RedirectURIRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -107,6 +144,9 @@ func (o RedirectURIRequest) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["matching_mode"] = o.MatchingMode toSerialize["url"] = o.Url + if !IsNil(o.RedirectUriType) { + toSerialize["redirect_uri_type"] = o.RedirectUriType + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -153,6 +193,7 @@ func (o *RedirectURIRequest) UnmarshalJSON(data []byte) (err error) { if err = json.Unmarshal(data, &additionalProperties); err == nil { delete(additionalProperties, "matching_mode") delete(additionalProperties, "url") + delete(additionalProperties, "redirect_uri_type") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_redirect_uri_type_enum.go b/packages/client-go/model_redirect_uri_type_enum.go new file mode 100644 index 0000000000..8c26452aa8 --- /dev/null +++ b/packages/client-go/model_redirect_uri_type_enum.go @@ -0,0 +1,111 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// RedirectUriTypeEnum the model 'RedirectUriTypeEnum' +type RedirectUriTypeEnum string + +// List of RedirectUriTypeEnum +const ( + REDIRECTURITYPEENUM_AUTHORIZATION RedirectUriTypeEnum = "authorization" + REDIRECTURITYPEENUM_LOGOUT RedirectUriTypeEnum = "logout" +) + +// All allowed values of RedirectUriTypeEnum enum +var AllowedRedirectUriTypeEnumEnumValues = []RedirectUriTypeEnum{ + "authorization", + "logout", +} + +func (v *RedirectUriTypeEnum) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := RedirectUriTypeEnum(value) + for _, existing := range AllowedRedirectUriTypeEnumEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid RedirectUriTypeEnum", value) +} + +// NewRedirectUriTypeEnumFromValue returns a pointer to a valid RedirectUriTypeEnum +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewRedirectUriTypeEnumFromValue(v string) (*RedirectUriTypeEnum, error) { + ev := RedirectUriTypeEnum(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for RedirectUriTypeEnum: valid values are %v", v, AllowedRedirectUriTypeEnumEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v RedirectUriTypeEnum) IsValid() bool { + for _, existing := range AllowedRedirectUriTypeEnumEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to RedirectUriTypeEnum value +func (v RedirectUriTypeEnum) Ptr() *RedirectUriTypeEnum { + return &v +} + +type NullableRedirectUriTypeEnum struct { + value *RedirectUriTypeEnum + isSet bool +} + +func (v NullableRedirectUriTypeEnum) Get() *RedirectUriTypeEnum { + return v.value +} + +func (v *NullableRedirectUriTypeEnum) Set(val *RedirectUriTypeEnum) { + v.value = val + v.isSet = true +} + +func (v NullableRedirectUriTypeEnum) IsSet() bool { + return v.isSet +} + +func (v *NullableRedirectUriTypeEnum) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableRedirectUriTypeEnum(val *RedirectUriTypeEnum) *NullableRedirectUriTypeEnum { + return &NullableRedirectUriTypeEnum{value: val, isSet: true} +} + +func (v NullableRedirectUriTypeEnum) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableRedirectUriTypeEnum) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-rust/src/models/mod.rs b/packages/client-rust/src/models/mod.rs index 298a86d252..f916734817 100644 --- a/packages/client-rust/src/models/mod.rs +++ b/packages/client-rust/src/models/mod.rs @@ -1394,6 +1394,8 @@ pub mod redirect_uri; pub use self::redirect_uri::RedirectUri; pub mod redirect_uri_request; pub use self::redirect_uri_request::RedirectUriRequest; +pub mod redirect_uri_type_enum; +pub use self::redirect_uri_type_enum::RedirectUriTypeEnum; pub mod related_group; pub use self::related_group::RelatedGroup; pub mod reputation; diff --git a/packages/client-rust/src/models/redirect_uri.rs b/packages/client-rust/src/models/redirect_uri.rs index d8eb4054c6..b7b03e0d09 100644 --- a/packages/client-rust/src/models/redirect_uri.rs +++ b/packages/client-rust/src/models/redirect_uri.rs @@ -17,11 +17,17 @@ pub struct RedirectUri { pub matching_mode: models::MatchingModeEnum, #[serde(rename = "url")] pub url: String, + #[serde(rename = "redirect_uri_type", skip_serializing_if = "Option::is_none")] + pub redirect_uri_type: Option, } impl RedirectUri { /// A single allowed redirect URI entry pub fn new(matching_mode: models::MatchingModeEnum, url: String) -> RedirectUri { - RedirectUri { matching_mode, url } + RedirectUri { + matching_mode, + url, + redirect_uri_type: None, + } } } diff --git a/packages/client-rust/src/models/redirect_uri_request.rs b/packages/client-rust/src/models/redirect_uri_request.rs index a08f425538..df8fd146da 100644 --- a/packages/client-rust/src/models/redirect_uri_request.rs +++ b/packages/client-rust/src/models/redirect_uri_request.rs @@ -17,11 +17,17 @@ pub struct RedirectUriRequest { pub matching_mode: models::MatchingModeEnum, #[serde(rename = "url")] pub url: String, + #[serde(rename = "redirect_uri_type", skip_serializing_if = "Option::is_none")] + pub redirect_uri_type: Option, } impl RedirectUriRequest { /// A single allowed redirect URI entry pub fn new(matching_mode: models::MatchingModeEnum, url: String) -> RedirectUriRequest { - RedirectUriRequest { matching_mode, url } + RedirectUriRequest { + matching_mode, + url, + redirect_uri_type: None, + } } } diff --git a/packages/client-rust/src/models/redirect_uri_type_enum.rs b/packages/client-rust/src/models/redirect_uri_type_enum.rs new file mode 100644 index 0000000000..b93deb627b --- /dev/null +++ b/packages/client-rust/src/models/redirect_uri_type_enum.rs @@ -0,0 +1,35 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum RedirectUriTypeEnum { + #[serde(rename = "authorization")] + Authorization, + #[serde(rename = "logout")] + Logout, +} + +impl std::fmt::Display for RedirectUriTypeEnum { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Authorization => write!(f, "authorization"), + Self::Logout => write!(f, "logout"), + } + } +} + +impl Default for RedirectUriTypeEnum { + fn default() -> RedirectUriTypeEnum { + Self::Authorization + } +} diff --git a/packages/client-ts/src/models/RedirectURI.ts b/packages/client-ts/src/models/RedirectURI.ts index 29d8ed6eaf..0e5e94881f 100644 --- a/packages/client-ts/src/models/RedirectURI.ts +++ b/packages/client-ts/src/models/RedirectURI.ts @@ -14,6 +14,8 @@ import type { MatchingModeEnum } from "./MatchingModeEnum"; import { MatchingModeEnumFromJSON, MatchingModeEnumToJSON } from "./MatchingModeEnum"; +import type { RedirectUriTypeEnum } from "./RedirectUriTypeEnum"; +import { RedirectUriTypeEnumFromJSON, RedirectUriTypeEnumToJSON } from "./RedirectUriTypeEnum"; /** * A single allowed redirect URI entry @@ -33,6 +35,12 @@ export interface RedirectURI { * @memberof RedirectURI */ url: string; + /** + * + * @type {RedirectUriTypeEnum} + * @memberof RedirectURI + */ + redirectUriType?: RedirectUriTypeEnum; } /** @@ -55,6 +63,10 @@ export function RedirectURIFromJSONTyped(json: any, ignoreDiscriminator: boolean return { matchingMode: MatchingModeEnumFromJSON(json["matching_mode"]), url: json["url"], + redirectUriType: + json["redirect_uri_type"] == null + ? undefined + : RedirectUriTypeEnumFromJSON(json["redirect_uri_type"]), }; } @@ -73,5 +85,6 @@ export function RedirectURIToJSONTyped( return { matching_mode: MatchingModeEnumToJSON(value["matchingMode"]), url: value["url"], + redirect_uri_type: RedirectUriTypeEnumToJSON(value["redirectUriType"]), }; } diff --git a/packages/client-ts/src/models/RedirectURIRequest.ts b/packages/client-ts/src/models/RedirectURIRequest.ts index c8a14629e2..7f4657c391 100644 --- a/packages/client-ts/src/models/RedirectURIRequest.ts +++ b/packages/client-ts/src/models/RedirectURIRequest.ts @@ -14,6 +14,8 @@ import type { MatchingModeEnum } from "./MatchingModeEnum"; import { MatchingModeEnumFromJSON, MatchingModeEnumToJSON } from "./MatchingModeEnum"; +import type { RedirectUriTypeEnum } from "./RedirectUriTypeEnum"; +import { RedirectUriTypeEnumFromJSON, RedirectUriTypeEnumToJSON } from "./RedirectUriTypeEnum"; /** * A single allowed redirect URI entry @@ -33,6 +35,12 @@ export interface RedirectURIRequest { * @memberof RedirectURIRequest */ url: string; + /** + * + * @type {RedirectUriTypeEnum} + * @memberof RedirectURIRequest + */ + redirectUriType?: RedirectUriTypeEnum; } /** @@ -58,6 +66,10 @@ export function RedirectURIRequestFromJSONTyped( return { matchingMode: MatchingModeEnumFromJSON(json["matching_mode"]), url: json["url"], + redirectUriType: + json["redirect_uri_type"] == null + ? undefined + : RedirectUriTypeEnumFromJSON(json["redirect_uri_type"]), }; } @@ -76,5 +88,6 @@ export function RedirectURIRequestToJSONTyped( return { matching_mode: MatchingModeEnumToJSON(value["matchingMode"]), url: value["url"], + redirect_uri_type: RedirectUriTypeEnumToJSON(value["redirectUriType"]), }; } diff --git a/packages/client-ts/src/models/RedirectUriTypeEnum.ts b/packages/client-ts/src/models/RedirectUriTypeEnum.ts new file mode 100644 index 0000000000..664e976f3f --- /dev/null +++ b/packages/client-ts/src/models/RedirectUriTypeEnum.ts @@ -0,0 +1,57 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + */ +export const RedirectUriTypeEnum = { + Authorization: "authorization", + Logout: "logout", + UnknownDefaultOpenApi: "11184809", +} as const; +export type RedirectUriTypeEnum = (typeof RedirectUriTypeEnum)[keyof typeof RedirectUriTypeEnum]; + +export function instanceOfRedirectUriTypeEnum(value: any): boolean { + for (const key in RedirectUriTypeEnum) { + if (Object.prototype.hasOwnProperty.call(RedirectUriTypeEnum, key)) { + if (RedirectUriTypeEnum[key as keyof typeof RedirectUriTypeEnum] === value) { + return true; + } + } + } + return false; +} + +export function RedirectUriTypeEnumFromJSON(json: any): RedirectUriTypeEnum { + return RedirectUriTypeEnumFromJSONTyped(json, false); +} + +export function RedirectUriTypeEnumFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): RedirectUriTypeEnum { + return json as RedirectUriTypeEnum; +} + +export function RedirectUriTypeEnumToJSON(value?: RedirectUriTypeEnum | null): any { + return value as any; +} + +export function RedirectUriTypeEnumToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): RedirectUriTypeEnum { + return value as RedirectUriTypeEnum; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index ab2638432c..e24455ffaf 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -698,6 +698,7 @@ export * from "./RedirectStageModeEnum"; export * from "./RedirectStageRequest"; export * from "./RedirectURI"; export * from "./RedirectURIRequest"; +export * from "./RedirectUriTypeEnum"; export * from "./RelatedGroup"; export * from "./Reputation"; export * from "./ReputationPolicy"; diff --git a/schema.yml b/schema.yml index d90cd2fcd8..da79798e0f 100644 --- a/schema.yml +++ b/schema.yml @@ -52856,6 +52856,10 @@ components: $ref: '#/components/schemas/MatchingModeEnum' url: type: string + redirect_uri_type: + allOf: + - $ref: '#/components/schemas/RedirectUriTypeEnum' + default: authorization required: - matching_mode - url @@ -52868,9 +52872,18 @@ components: url: type: string minLength: 1 + redirect_uri_type: + allOf: + - $ref: '#/components/schemas/RedirectUriTypeEnum' + default: authorization required: - matching_mode - url + RedirectUriTypeEnum: + enum: + - authorization + - logout + type: string RelatedGroup: type: object description: Stripped down group serializer to show relevant children/parents diff --git a/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts b/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts index 994bcf99b9..4e32cef5c5 100644 --- a/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts +++ b/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts @@ -18,6 +18,7 @@ import { RACProvider, RadiusProvider, RedirectURI, + RedirectUriTypeEnum, SAMLProvider, SCIMProvider, WSFederationProvider, @@ -85,7 +86,10 @@ function formatRedirectUris(uris: RedirectURI[] = []) { ${uri.url} (${uri.matchingMode === MatchingModeEnum.Strict ? msg("strict") - : msg("regexp")}) + : msg("regexp")}, + ${uri.redirectUriType === RedirectUriTypeEnum.Logout + ? msg("post logout") + : msg("authorization")}) `, )} ` diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts index 1e6cef704f..33d92bf7b1 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts @@ -34,6 +34,7 @@ import { OAuth2Provider, OAuth2ProviderLogoutMethodEnum, RedirectURI, + RedirectUriTypeEnum, SubModeEnum, ValidationError, } from "@goauthentik/api"; @@ -120,10 +121,10 @@ export const issuerModeOptions: RadioOption[] = [ const redirectUriHelpMessages: string[] = [ msg( - "Valid redirect URIs after a successful authorization flow. Also specify any origins here for Implicit flows.", + "Valid redirect URIs after a successful authorization or invalidation flow. Also specify any origins here for Implicit flows. Use the type dropdown to designate URIs for authorization or post-logout redirection.", ), msg( - "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", + "If no explicit authorization redirect URIs are specified, the first successfully used authorization redirect URI will be saved.", ), msg( 'To allow any redirect URI, set the mode to Regex and the value to ".*". Be aware of the possible security implications this can have.', @@ -221,7 +222,11 @@ export function renderForm({ > ({ matchingMode: MatchingModeEnum.Strict, url: "" })} + .newItem=${() => ({ + matchingMode: MatchingModeEnum.Strict, + url: "", + redirectUriType: RedirectUriTypeEnum.Authorization, + })} .row=${(redirectURI: RedirectURI, idx: number) => { return html` { public redirectURI: RedirectURI = { matchingMode: MatchingModeEnum.Strict, url: "", + redirectUriType: RedirectUriTypeEnum.Authorization, }; @property({ type: String, useDefault: true }) @@ -82,6 +83,25 @@ export class OAuth2ProviderRedirectURI extends AKControlElement { ${msg("Regex")} +