mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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 <connor@connorpeshek.me> Co-authored-by: Jens L. <jens@goauthentik.io>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
263
authentik/providers/oauth2/tests/test_end_session.py
Normal file
263
authentik/providers/oauth2/tests/test_end_session.py
Normal file
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10019,6 +10019,14 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Url"
|
||||
},
|
||||
"redirect_uri_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"authorization",
|
||||
"logout"
|
||||
],
|
||||
"title": "Redirect uri type"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
45
packages/client-go/model_redirect_uri.go
generated
45
packages/client-go/model_redirect_uri.go
generated
@@ -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
|
||||
}
|
||||
|
||||
|
||||
45
packages/client-go/model_redirect_uri_request.go
generated
45
packages/client-go/model_redirect_uri_request.go
generated
@@ -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
|
||||
}
|
||||
|
||||
|
||||
111
packages/client-go/model_redirect_uri_type_enum.go
generated
Normal file
111
packages/client-go/model_redirect_uri_type_enum.go
generated
Normal file
@@ -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)
|
||||
}
|
||||
2
packages/client-rust/src/models/mod.rs
generated
2
packages/client-rust/src/models/mod.rs
generated
@@ -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;
|
||||
|
||||
8
packages/client-rust/src/models/redirect_uri.rs
generated
8
packages/client-rust/src/models/redirect_uri.rs
generated
@@ -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<models::RedirectUriTypeEnum>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<models::RedirectUriTypeEnum>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
packages/client-rust/src/models/redirect_uri_type_enum.rs
generated
Normal file
35
packages/client-rust/src/models/redirect_uri_type_enum.rs
generated
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
13
packages/client-ts/src/models/RedirectURI.ts
generated
13
packages/client-ts/src/models/RedirectURI.ts
generated
@@ -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"]),
|
||||
};
|
||||
}
|
||||
|
||||
13
packages/client-ts/src/models/RedirectURIRequest.ts
generated
13
packages/client-ts/src/models/RedirectURIRequest.ts
generated
@@ -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"]),
|
||||
};
|
||||
}
|
||||
|
||||
57
packages/client-ts/src/models/RedirectUriTypeEnum.ts
generated
Normal file
57
packages/client-ts/src/models/RedirectUriTypeEnum.ts
generated
Normal file
@@ -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;
|
||||
}
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -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";
|
||||
|
||||
13
schema.yml
13
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
|
||||
|
||||
@@ -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")})
|
||||
</li>`,
|
||||
)}
|
||||
</ul>`
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
OAuth2Provider,
|
||||
OAuth2ProviderLogoutMethodEnum,
|
||||
RedirectURI,
|
||||
RedirectUriTypeEnum,
|
||||
SubModeEnum,
|
||||
ValidationError,
|
||||
} from "@goauthentik/api";
|
||||
@@ -120,10 +121,10 @@ export const issuerModeOptions: RadioOption<IssuerModeEnum>[] = [
|
||||
|
||||
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({
|
||||
>
|
||||
<ak-array-input
|
||||
.items=${provider.redirectUris ?? []}
|
||||
.newItem=${() => ({ matchingMode: MatchingModeEnum.Strict, url: "" })}
|
||||
.newItem=${() => ({
|
||||
matchingMode: MatchingModeEnum.Strict,
|
||||
url: "",
|
||||
redirectUriType: RedirectUriTypeEnum.Authorization,
|
||||
})}
|
||||
.row=${(redirectURI: RedirectURI, idx: number) => {
|
||||
return html`<ak-provider-oauth2-redirect-uri
|
||||
.redirectURI=${redirectURI}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AKControlElement } from "#elements/ControlElement";
|
||||
import { LitPropertyRecord } from "#elements/types";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { MatchingModeEnum, RedirectURI } from "@goauthentik/api";
|
||||
import { MatchingModeEnum, RedirectURI, RedirectUriTypeEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
@@ -37,6 +37,7 @@ export class OAuth2ProviderRedirectURI extends AKControlElement<RedirectURI> {
|
||||
public redirectURI: RedirectURI = {
|
||||
matchingMode: MatchingModeEnum.Strict,
|
||||
url: "",
|
||||
redirectUriType: RedirectUriTypeEnum.Authorization,
|
||||
};
|
||||
|
||||
@property({ type: String, useDefault: true })
|
||||
@@ -82,6 +83,25 @@ export class OAuth2ProviderRedirectURI extends AKControlElement<RedirectURI> {
|
||||
${msg("Regex")}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
name="redirectUriType"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
@change=${onChange}
|
||||
>
|
||||
<option
|
||||
value="${RedirectUriTypeEnum.Authorization}"
|
||||
?selected=${(this.redirectURI.redirectUriType ??
|
||||
RedirectUriTypeEnum.Authorization) === RedirectUriTypeEnum.Authorization}
|
||||
>
|
||||
${msg("Authorization")}
|
||||
</option>
|
||||
<option
|
||||
value="${RedirectUriTypeEnum.Logout}"
|
||||
?selected=${this.redirectURI.redirectUriType === RedirectUriTypeEnum.Logout}
|
||||
>
|
||||
${msg("Post Logout")}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
|
||||
Reference in New Issue
Block a user