mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 07:02:51 +02:00
Compare commits
6 Commits
saml-sourc
...
ak_params
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119c9d018a | ||
|
|
d6c0ae21de | ||
|
|
2c35df35b6 | ||
|
|
90d4f4296b | ||
|
|
bf7747268b | ||
|
|
552cb78458 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -3000,9 +3000,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.39"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
|
||||
@@ -66,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.39", features = ["fips"] }
|
||||
rustls = { version = "= 0.23.40", features = ["fips"] }
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Shared SAML LogoutResponse parser"""
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from lxml.etree import _Element # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_PROTOCOL, SAML_STATUS_SUCCESS
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LogoutResponseParser:
|
||||
"""Parse and validate SAML LogoutResponse messages"""
|
||||
|
||||
_root: _Element
|
||||
|
||||
def __init__(self, raw_response: str):
|
||||
self._raw_response = raw_response
|
||||
|
||||
def parse(self):
|
||||
"""Decode and parse the LogoutResponse XML."""
|
||||
# decode_base64_and_inflate handles both deflate-compressed (Redirect binding)
|
||||
# and plain base64 (POST binding) responses
|
||||
response_xml = decode_base64_and_inflate(self._raw_response)
|
||||
self._root = fromstring(response_xml.encode())
|
||||
|
||||
def verify_status(self) -> bool:
|
||||
"""Check LogoutResponse status. Returns True if status is Success."""
|
||||
status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
if status is None:
|
||||
return True
|
||||
status_code = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
if status_code is None:
|
||||
return True
|
||||
status_value = status_code.attrib.get("Value", "")
|
||||
if status_value != SAML_STATUS_SUCCESS:
|
||||
LOGGER.warning(
|
||||
"LogoutResponse status is not Success",
|
||||
status=status_value,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Common SAML Exceptions"""
|
||||
"""authentik SAML IDP Exceptions"""
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
@@ -19,8 +19,8 @@ from authentik.common.saml.constants import (
|
||||
RSA_SHA512,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared SAML LogoutRequest parser"""
|
||||
"""LogoutRequest parser"""
|
||||
|
||||
from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
@@ -6,29 +6,41 @@ from dataclasses import dataclass
|
||||
from defusedxml import ElementTree
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LogoutRequest:
|
||||
"""Parsed SAML LogoutRequest"""
|
||||
"""Logout Request"""
|
||||
|
||||
id: str | None = None
|
||||
|
||||
issuer: str | None = None
|
||||
|
||||
name_id: str | None = None
|
||||
|
||||
name_id_format: str | None = None
|
||||
|
||||
session_index: str | None = None
|
||||
|
||||
relay_state: str | None = None
|
||||
|
||||
|
||||
class LogoutRequestParser:
|
||||
"""Parse incoming SAML LogoutRequest messages"""
|
||||
"""LogoutRequest Parser"""
|
||||
|
||||
provider: SAMLProvider
|
||||
|
||||
def __init__(self, provider: SAMLProvider):
|
||||
self.provider = provider
|
||||
|
||||
def _parse_xml(self, decoded_xml: str | bytes, relay_state: str | None = None) -> LogoutRequest:
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
request = LogoutRequest(
|
||||
id=root.attrib.get("ID"),
|
||||
id=root.attrib["ID"],
|
||||
)
|
||||
# Try both namespaces for Issuer
|
||||
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
|
||||
@@ -43,6 +55,7 @@ class LogoutRequestParser:
|
||||
name_ids = root.findall(f"{{{NS_SAML_PROTOCOL}}}NameID")
|
||||
if len(name_ids) > 0:
|
||||
request.name_id = name_ids[0].text
|
||||
# Extract NameID Format if present
|
||||
if "Format" in name_ids[0].attrib:
|
||||
request.name_id_format = name_ids[0].attrib["Format"]
|
||||
|
||||
@@ -57,17 +70,22 @@ class LogoutRequestParser:
|
||||
return request
|
||||
|
||||
def parse(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
|
||||
"""Parse a POST-binding LogoutRequest (base64 encoded)."""
|
||||
"""Validate and parse raw request with enveloped signautre."""
|
||||
try:
|
||||
decoded_xml = b64decode(saml_request.encode())
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion("Cannot decode SAML request") from None
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
|
||||
def parse_detached(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
|
||||
"""Parse a Redirect-binding LogoutRequest (deflate + base64 encoded)."""
|
||||
def parse_detached(
|
||||
self,
|
||||
saml_request: str,
|
||||
relay_state: str | None = None,
|
||||
) -> LogoutRequest:
|
||||
"""Validate and parse raw request with detached signature"""
|
||||
try:
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion("Cannot decode SAML request") from None
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
|
||||
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
@@ -15,8 +15,8 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
|
||||
@@ -5,10 +5,10 @@ from django.contrib.auth import get_user_model
|
||||
from dramatiq.actor import actor
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -8,10 +8,10 @@ from authentik.common.saml.constants import (
|
||||
RSA_SHA256,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
|
||||
|
||||
class TestLogoutIntegration(TestCase):
|
||||
@@ -46,7 +46,7 @@ class TestLogoutIntegration(TestCase):
|
||||
)
|
||||
|
||||
# Create parser for validation
|
||||
self.parser = LogoutRequestParser()
|
||||
self.parser = LogoutRequestParser(self.provider)
|
||||
|
||||
def test_post_binding_roundtrip(self):
|
||||
"""Test that a POST-encoded request can be parsed correctly"""
|
||||
@@ -100,7 +100,7 @@ class TestLogoutIntegration(TestCase):
|
||||
encoded = processor.encode_post()
|
||||
|
||||
# Create parser with verification enabled
|
||||
parser = LogoutRequestParser()
|
||||
parser = LogoutRequestParser(self.provider)
|
||||
|
||||
# Parse it - this would validate signature if verification is enabled
|
||||
parsed = parser.parse(encoded)
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_TRANSIENT
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
GET_LOGOUT_REQUEST = (
|
||||
@@ -51,7 +51,7 @@ class TestLogoutRequest(TestCase):
|
||||
|
||||
def test_static_get(self):
|
||||
"""Test static LogoutRequest"""
|
||||
request = LogoutRequestParser().parse_detached(GET_LOGOUT_REQUEST)
|
||||
request = LogoutRequestParser(self.provider).parse_detached(GET_LOGOUT_REQUEST)
|
||||
self.assertEqual(request.id, "id-2ea1b01f69363ac95e3da4a15409b9d8ec525944")
|
||||
self.assertEqual(request.issuer, "saml-test-sp")
|
||||
# The GET request has an empty NameID element with transient format
|
||||
@@ -60,7 +60,7 @@ class TestLogoutRequest(TestCase):
|
||||
|
||||
def test_static_post(self):
|
||||
"""Test static LogoutRequest"""
|
||||
request = LogoutRequestParser().parse(POST_LOGOUT_REQUEST)
|
||||
request = LogoutRequestParser(self.provider).parse(POST_LOGOUT_REQUEST)
|
||||
self.assertEqual(request.id, "id-b8f4fd51ed4106f1e782b95d51d9ad3f385e5816")
|
||||
self.assertEqual(request.issuer, "saml-test-sp")
|
||||
# The POST request has an empty NameID element with transient format
|
||||
|
||||
@@ -9,11 +9,11 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.views.flows import (
|
||||
|
||||
@@ -7,8 +7,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow, in_memory_stage
|
||||
@@ -18,6 +16,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.iframe_logout import IframeLogoutStageView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import (
|
||||
SAMLBindings,
|
||||
SAMLLogoutMethods,
|
||||
@@ -25,6 +24,7 @@ from authentik.providers.saml.models import (
|
||||
SAMLSession,
|
||||
)
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.tasks import send_saml_logout_response
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
@@ -251,7 +251,7 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
|
||||
return bad_request_message(self.request, "The SAML request payload is missing.")
|
||||
|
||||
try:
|
||||
logout_request = LogoutRequestParser().parse_detached(
|
||||
logout_request = LogoutRequestParser(self.provider).parse_detached(
|
||||
self.request.GET[REQUEST_KEY_SAML_REQUEST],
|
||||
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
|
||||
)
|
||||
@@ -295,7 +295,7 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
|
||||
return bad_request_message(self.request, "The SAML request payload is missing.")
|
||||
|
||||
try:
|
||||
logout_request = LogoutRequestParser().parse(
|
||||
logout_request = LogoutRequestParser(self.provider).parse(
|
||||
payload[REQUEST_KEY_SAML_REQUEST],
|
||||
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
@@ -17,6 +16,7 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
|
||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||
from authentik.providers.saml.views.flows import (
|
||||
|
||||
@@ -43,7 +43,6 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||
"force_authn",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_binding",
|
||||
"verification_kp",
|
||||
"signing_kp",
|
||||
"digest_algorithm",
|
||||
@@ -52,8 +51,6 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||
"encryption_kp",
|
||||
"signed_assertion",
|
||||
"signed_response",
|
||||
"sign_authn_request",
|
||||
"sign_logout_request",
|
||||
]
|
||||
|
||||
|
||||
@@ -81,7 +78,6 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
"force_authn",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_binding",
|
||||
"verification_kp",
|
||||
"signing_kp",
|
||||
"digest_algorithm",
|
||||
@@ -89,8 +85,6 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
"temporary_user_delete_after",
|
||||
"signed_assertion",
|
||||
"signed_response",
|
||||
"sign_authn_request",
|
||||
"sign_logout_request",
|
||||
]
|
||||
search_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-09 22:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
("authentik_sources_saml", "0021_samlsource_signed_assertion_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="sign_authn_request",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
|
||||
verbose_name="Sign AuthnRequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="sign_logout_request",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
|
||||
verbose_name="Sign LogoutRequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="slo_binding",
|
||||
field=models.CharField(
|
||||
choices=[("REDIRECT", "Redirect Binding"), ("POST", "POST Binding")],
|
||||
default="REDIRECT",
|
||||
help_text="Binding type for Single Logout requests to the IdP.",
|
||||
max_length=100,
|
||||
verbose_name="SLO Binding",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLSourceSession",
|
||||
fields=[
|
||||
(
|
||||
"saml_session_id",
|
||||
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
(
|
||||
"session_index",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="SAML SessionIndex from the IdP's AuthnStatement",
|
||||
),
|
||||
),
|
||||
("name_id", models.TextField(help_text="SAML NameID value for this session")),
|
||||
(
|
||||
"name_id_format",
|
||||
models.TextField(blank=True, default="", help_text="SAML NameID format"),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"session",
|
||||
models.ForeignKey(
|
||||
help_text="Link to the user's authenticated session",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_sources_saml.samlsource",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SAML Source Session",
|
||||
"verbose_name_plural": "SAML Source Sessions",
|
||||
"indexes": [
|
||||
models.Index(fields=["source", "user"], name="authentik_s_source__abd088_idx"),
|
||||
models.Index(fields=["session"], name="authentik_s_session_054d2d_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
"""saml sp models"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -37,11 +36,9 @@ from authentik.common.saml.constants import (
|
||||
SHA512,
|
||||
)
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
GroupSourceConnection,
|
||||
PropertyMapping,
|
||||
Source,
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
@@ -81,13 +78,6 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
|
||||
|
||||
class SAMLSLOBindingTypes(models.TextChoices):
|
||||
"""SAML SLO Binding types"""
|
||||
|
||||
REDIRECT = "REDIRECT", _("Redirect Binding")
|
||||
POST = "POST", _("POST Binding")
|
||||
|
||||
|
||||
class SAMLSource(Source):
|
||||
"""Authenticate using an external SAML Identity Provider."""
|
||||
|
||||
@@ -144,28 +134,6 @@ class SAMLSource(Source):
|
||||
choices=SAMLBindingTypes.choices,
|
||||
default=SAMLBindingTypes.REDIRECT,
|
||||
)
|
||||
slo_binding = models.CharField(
|
||||
max_length=100,
|
||||
choices=SAMLSLOBindingTypes.choices,
|
||||
default=SAMLSLOBindingTypes.REDIRECT,
|
||||
verbose_name=_("SLO Binding"),
|
||||
help_text=_("Binding type for Single Logout requests to the IdP."),
|
||||
)
|
||||
|
||||
sign_authn_request = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sign AuthnRequest"),
|
||||
help_text=_(
|
||||
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
|
||||
),
|
||||
)
|
||||
sign_logout_request = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sign LogoutRequest"),
|
||||
help_text=_(
|
||||
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
|
||||
),
|
||||
)
|
||||
|
||||
temporary_user_delete_after = models.TextField(
|
||||
default="days=1",
|
||||
@@ -387,39 +355,3 @@ class GroupSAMLSourceConnection(GroupSourceConnection):
|
||||
class Meta:
|
||||
verbose_name = _("Group SAML Source Connection")
|
||||
verbose_name_plural = _("Group SAML Source Connections")
|
||||
|
||||
|
||||
class SAMLSourceSession(models.Model):
|
||||
"""Track active SAML source sessions for Single Logout support"""
|
||||
|
||||
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
|
||||
source = models.ForeignKey(SAMLSource, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession,
|
||||
on_delete=models.CASCADE,
|
||||
help_text=_("Link to the user's authenticated session"),
|
||||
)
|
||||
session_index = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_("SAML SessionIndex from the IdP's AuthnStatement"),
|
||||
)
|
||||
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
|
||||
name_id_format = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_("SAML NameID format"),
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SAML Source Session")
|
||||
verbose_name_plural = _("SAML Source Sessions")
|
||||
indexes = [
|
||||
models.Index(fields=["source", "user"]),
|
||||
models.Index(fields=["session"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Source Session for source {self.source_id} and user {self.user_id}"
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
"""SAML Source LogoutRequest Processor"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import xmlsec
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, _Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.lib.xml import remove_xml_newlines
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class LogoutRequestProcessor:
|
||||
"""Generate SAML LogoutRequest messages for SP-initiated logout"""
|
||||
|
||||
source: SAMLSource
|
||||
http_request: HttpRequest
|
||||
destination: str
|
||||
name_id: str
|
||||
name_id_format: str
|
||||
session_index: str
|
||||
relay_state: str | None
|
||||
|
||||
_issue_instant: str
|
||||
_request_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: SAMLSource,
|
||||
http_request: HttpRequest,
|
||||
destination: str,
|
||||
name_id: str,
|
||||
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index: str = "",
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.http_request = http_request
|
||||
self.destination = destination
|
||||
self.name_id = name_id
|
||||
self.name_id_format = name_id_format
|
||||
self.session_index = session_index
|
||||
self.relay_state = relay_state
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._request_id = get_random_id()
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.source.get_issuer(self.http_request)
|
||||
return issuer
|
||||
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
name_id.attrib["Format"] = self.name_id_format
|
||||
name_id.text = self.name_id
|
||||
return name_id
|
||||
|
||||
def build(self) -> Element:
|
||||
"""Build a SAML LogoutRequest as etree Element"""
|
||||
logout_request = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutRequest", nsmap=NS_MAP)
|
||||
logout_request.attrib["ID"] = self._request_id
|
||||
logout_request.attrib["Version"] = "2.0"
|
||||
logout_request.attrib["IssueInstant"] = self._issue_instant
|
||||
logout_request.attrib["Destination"] = self.destination
|
||||
|
||||
logout_request.append(self.get_issuer())
|
||||
logout_request.append(self.get_name_id())
|
||||
|
||||
if self.session_index:
|
||||
session_index_element = Element(f"{{{NS_SAML_PROTOCOL}}}SessionIndex")
|
||||
session_index_element.text = self.session_index
|
||||
logout_request.append(session_index_element)
|
||||
|
||||
return logout_request
|
||||
|
||||
def encode_post(self) -> str:
|
||||
"""Encode LogoutRequest for POST binding"""
|
||||
logout_request = self.build()
|
||||
if self.source.signing_kp and self.source.sign_logout_request:
|
||||
self._sign_logout_request(logout_request)
|
||||
return base64.b64encode(etree.tostring(logout_request)).decode()
|
||||
|
||||
def encode_redirect(self) -> str:
|
||||
"""Encode LogoutRequest for Redirect binding"""
|
||||
logout_request = self.build()
|
||||
xml_str = etree.tostring(logout_request, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self) -> str:
|
||||
"""Build complete logout URL for redirect binding with signature if needed"""
|
||||
encoded_request = self.encode_redirect()
|
||||
params = {
|
||||
"SAMLRequest": encoded_request,
|
||||
}
|
||||
|
||||
if self.relay_state:
|
||||
params["RelayState"] = self.relay_state
|
||||
|
||||
if self.source.signing_kp and self.source.sign_logout_request:
|
||||
sig_alg = self.source.signature_algorithm
|
||||
params["SigAlg"] = sig_alg
|
||||
|
||||
query_string = self._build_signable_query_string(params)
|
||||
signature = self._sign_query_string(query_string)
|
||||
params["Signature"] = base64.b64encode(signature).decode()
|
||||
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def get_post_form_data(self) -> dict:
|
||||
"""Get form data for POST binding"""
|
||||
return {
|
||||
"SAMLRequest": self.encode_post(),
|
||||
"RelayState": self.relay_state or "",
|
||||
}
|
||||
|
||||
def _sign_logout_request(self, logout_request: _Element):
|
||||
"""Sign the LogoutRequest element"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
logout_request,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
signature_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
|
||||
issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
if issuer is not None:
|
||||
issuer.addnext(signature)
|
||||
else:
|
||||
logout_request.insert(0, signature)
|
||||
|
||||
self._sign(logout_request)
|
||||
|
||||
def _sign(self, element: _Element):
|
||||
"""Sign an XML element based on the source's configured signing settings"""
|
||||
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
|
||||
self.source.digest_algorithm, xmlsec.constants.TransformSha1
|
||||
)
|
||||
xmlsec.tree.add_ids(element, ["ID"])
|
||||
signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + element.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
key_info = xmlsec.template.ensure_key_info(signature_node)
|
||||
xmlsec.template.add_x509_data(key_info)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.source.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
key.load_cert_from_memory(
|
||||
self.source.signing_kp.certificate_data,
|
||||
xmlsec.constants.KeyDataFormatCertPem,
|
||||
)
|
||||
ctx.key = key
|
||||
ctx.sign(remove_xml_newlines(element, signature_node))
|
||||
|
||||
def _build_signable_query_string(self, params: dict) -> str:
|
||||
"""Build query string for signing (order matters per SAML spec)"""
|
||||
ordered = []
|
||||
if "SAMLRequest" in params:
|
||||
ordered.append(f"SAMLRequest={quote(params['SAMLRequest'], safe='')}")
|
||||
if "RelayState" in params:
|
||||
ordered.append(f"RelayState={quote(params['RelayState'], safe='')}")
|
||||
if "SigAlg" in params:
|
||||
ordered.append(f"SigAlg={quote(params['SigAlg'], safe='')}")
|
||||
return "&".join(ordered)
|
||||
|
||||
def _sign_query_string(self, query_string: str) -> bytes:
|
||||
"""Sign the query string for redirect binding"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha256
|
||||
)
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.source.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = key
|
||||
|
||||
return ctx.sign_binary(query_string.encode("utf-8"), signature_algorithm_transform)
|
||||
@@ -1,96 +0,0 @@
|
||||
"""SAML Source LogoutResponse Builder"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_STATUS_SUCCESS,
|
||||
)
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class LogoutResponseBuilder:
|
||||
"""Build SAML LogoutResponse messages for IdP-initiated logout"""
|
||||
|
||||
source: SAMLSource
|
||||
http_request: HttpRequest
|
||||
destination: str
|
||||
in_response_to: str
|
||||
|
||||
_issue_instant: str
|
||||
_response_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: SAMLSource,
|
||||
http_request: HttpRequest,
|
||||
destination: str,
|
||||
in_response_to: str,
|
||||
):
|
||||
self.source = source
|
||||
self.http_request = http_request
|
||||
self.destination = destination
|
||||
self.in_response_to = in_response_to
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def build(self) -> Element:
|
||||
"""Build a SAML LogoutResponse as etree Element"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
|
||||
response.attrib["ID"] = self._response_id
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.destination
|
||||
response.attrib["InResponseTo"] = self.in_response_to
|
||||
|
||||
# Issuer
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.source.get_issuer(self.http_request)
|
||||
response.append(issuer)
|
||||
|
||||
# Status
|
||||
status = Element(f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = Element(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = SAML_STATUS_SUCCESS
|
||||
status.append(status_code)
|
||||
response.append(status)
|
||||
|
||||
return response
|
||||
|
||||
def encode_post(self) -> str:
|
||||
"""Encode LogoutResponse for POST binding"""
|
||||
response = self.build()
|
||||
return base64.b64encode(etree.tostring(response)).decode()
|
||||
|
||||
def encode_redirect(self) -> str:
|
||||
"""Encode LogoutResponse for Redirect binding"""
|
||||
response = self.build()
|
||||
xml_str = etree.tostring(response, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self, relay_state: str | None = None) -> str:
|
||||
"""Build complete URL for redirect binding"""
|
||||
encoded = self.encode_redirect()
|
||||
params = {"SAMLResponse": encoded}
|
||||
if relay_state:
|
||||
params["RelayState"] = relay_state
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def get_post_form_data(self, relay_state: str | None = None) -> dict:
|
||||
"""Get form data for POST binding"""
|
||||
data = {"SAMLResponse": self.encode_post()}
|
||||
if relay_state:
|
||||
data["RelayState"] = relay_state
|
||||
return data
|
||||
@@ -8,7 +8,6 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_METADATA,
|
||||
NS_SIGNATURE,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
)
|
||||
from authentik.providers.saml.utils.encoding import strip_pem_header
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
@@ -76,19 +75,6 @@ class MetadataProcessor:
|
||||
if encryption_descriptor is not None:
|
||||
sp_sso_descriptor.append(encryption_descriptor)
|
||||
|
||||
if self.source.slo_url:
|
||||
slo_location = self.source.build_full_url(self.http_request, view="slo")
|
||||
|
||||
slo_redirect = SubElement(
|
||||
sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService"
|
||||
)
|
||||
slo_redirect.attrib["Binding"] = SAML_BINDING_REDIRECT
|
||||
slo_redirect.attrib["Location"] = slo_location
|
||||
|
||||
slo_post = SubElement(sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService")
|
||||
slo_post.attrib["Binding"] = SAML_BINDING_POST
|
||||
slo_post.attrib["Location"] = slo_location
|
||||
|
||||
sp_sso_descriptor.append(self.get_name_id_format())
|
||||
|
||||
assertion_consumer_service = SubElement(
|
||||
|
||||
@@ -72,11 +72,7 @@ class RequestProcessor:
|
||||
# Create issuer object
|
||||
auth_n_request.append(self.get_issuer())
|
||||
|
||||
if (
|
||||
self.source.signing_kp
|
||||
and self.source.sign_authn_request
|
||||
and self.source.binding_type != SAMLBindingTypes.REDIRECT
|
||||
):
|
||||
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
@@ -97,11 +93,7 @@ class RequestProcessor:
|
||||
(used for POST Bindings)"""
|
||||
auth_n_request = self.get_auth_n()
|
||||
|
||||
if (
|
||||
self.source.signing_kp
|
||||
and self.source.sign_authn_request
|
||||
and self.source.binding_type != SAMLBindingTypes.REDIRECT
|
||||
):
|
||||
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
|
||||
xmlsec.tree.add_ids(auth_n_request, ["ID"])
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
@@ -149,7 +141,7 @@ class RequestProcessor:
|
||||
if self.relay_state != "":
|
||||
response_dict["RelayState"] = self.relay_state
|
||||
|
||||
if self.source.signing_kp and self.source.sign_authn_request:
|
||||
if self.source.signing_kp:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
|
||||
@@ -42,9 +42,12 @@ from authentik.sources.saml.exceptions import (
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
|
||||
from authentik.sources.saml.models import (
|
||||
GroupSAMLSourceConnection,
|
||||
SAMLSource,
|
||||
UserSAMLSourceConnection,
|
||||
)
|
||||
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
|
||||
from authentik.sources.saml.stages import PLAN_CONTEXT_SAML_SESSION_DATA, SAMLSourceFlowManager
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
@@ -237,7 +240,6 @@ class ResponseProcessor:
|
||||
UserSAMLSourceConnection.objects.create(
|
||||
source=self._source, user=user, identifier=name_id.text
|
||||
)
|
||||
session_index = self._get_session_index()
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
@@ -247,25 +249,9 @@ class ResponseProcessor:
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA: {
|
||||
"session_index": session_index or "",
|
||||
"name_id": name_id.text,
|
||||
"name_id_format": name_id.attrib.get("Format", ""),
|
||||
},
|
||||
},
|
||||
policy_context={},
|
||||
)
|
||||
|
||||
def _get_session_index(self) -> str | None:
|
||||
"""Get SessionIndex from AuthnStatement element"""
|
||||
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
if assertion is None:
|
||||
return None
|
||||
authn_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
if authn_statement is None:
|
||||
return None
|
||||
return authn_statement.attrib.get("SessionIndex")
|
||||
|
||||
def get_assertion(self) -> Element | None:
|
||||
"""Get assertion element, if we have a signed assertion"""
|
||||
if self._assertion is not None:
|
||||
@@ -321,7 +307,6 @@ class ResponseProcessor:
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
||||
return self._handle_name_id_transient()
|
||||
|
||||
session_index = self._get_session_index()
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
@@ -333,10 +318,12 @@ class ResponseProcessor:
|
||||
},
|
||||
policy_context={
|
||||
"saml_response": etree.tostring(self._root),
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA: {
|
||||
"session_index": session_index or "",
|
||||
"name_id": name_id.text,
|
||||
"name_id_format": name_id.attrib.get("Format", ""),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SAMLSourceFlowManager(SourceFlowManager):
|
||||
"""Source flow manager for SAML Sources"""
|
||||
|
||||
user_connection_type = UserSAMLSourceConnection
|
||||
group_connection_type = GroupSAMLSourceConnection
|
||||
|
||||
@@ -3,40 +3,12 @@
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, AuthenticatedSession, User
|
||||
from authentik.flows.challenge import PLAN_CONTEXT_ATTRS, PLAN_CONTEXT_TITLE, PLAN_CONTEXT_URL
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.stage import RedirectStage, SessionEndStage
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.sources.saml.models import SAMLSLOBindingTypes, SAMLSourceSession
|
||||
from authentik.sources.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.sources.saml.views import PLAN_CONTEXT_SAML_RELAY_STATE, AutosubmitStageView
|
||||
from authentik.stages.user_logout.models import UserLogoutStage
|
||||
from authentik.stages.user_logout.stage import flow_pre_user_logout
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, User
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# Stages that redirect the user away from authentik. Source SLO stages must be
|
||||
# inserted before these so they have a chance to execute.
|
||||
TERMINAL_STAGE_VIEWS = {SessionEndStage, NativeLogoutStageView}
|
||||
|
||||
|
||||
def _insert_before_terminal_stage(plan, stage):
|
||||
"""Insert a stage before any terminal stage (SessionEndStage, NativeLogoutStageView)
|
||||
in the plan. Falls back to append if no terminal stage is found."""
|
||||
for i, binding in enumerate(plan.bindings):
|
||||
try:
|
||||
if binding.stage.view in TERMINAL_STAGE_VIEWS:
|
||||
plan.insert_stage(stage, index=i)
|
||||
return
|
||||
except NotImplementedError:
|
||||
continue
|
||||
plan.append_stage(stage)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
@@ -46,89 +18,3 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
if user.attributes.get(USER_ATTRIBUTE_DELETE_ON_LOGOUT, False):
|
||||
LOGGER.debug("Deleted temporary user", user=user)
|
||||
user.delete()
|
||||
|
||||
|
||||
@receiver(flow_pre_user_logout)
|
||||
def handle_saml_source_pre_user_logout(
|
||||
sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
|
||||
):
|
||||
"""Handle SAML source SP-initiated SLO when user logs out via flow.
|
||||
Injects a stage into the logout flow to redirect the user to the IdP's SLO URL."""
|
||||
if not isinstance(executor.current_stage, UserLogoutStage):
|
||||
return
|
||||
|
||||
if not user.is_authenticated:
|
||||
return
|
||||
|
||||
auth_session = AuthenticatedSession.from_request(request, user)
|
||||
if not auth_session:
|
||||
return
|
||||
|
||||
# Find SAMLSourceSessions for this user's current session
|
||||
saml_source_sessions = SAMLSourceSession.objects.filter(
|
||||
session=auth_session,
|
||||
user=user,
|
||||
).select_related("source")
|
||||
|
||||
for saml_session in saml_source_sessions:
|
||||
source = saml_session.source
|
||||
if not source.slo_url or not source.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Use the flow executor URL as relay_state so that after the IdP
|
||||
# processes the LogoutRequest and sends a LogoutResponse, the user
|
||||
# is redirected back to the flow to continue remaining stages.
|
||||
relay_state = request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": executor.flow.slug},
|
||||
)
|
||||
)
|
||||
|
||||
# Stash the outbound relay_state so the SLOView can redirect to a
|
||||
# server-known value rather than trusting the echoed request param.
|
||||
executor.plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
|
||||
|
||||
processor = LogoutRequestProcessor(
|
||||
source=source,
|
||||
http_request=request,
|
||||
destination=source.slo_url,
|
||||
name_id=saml_session.name_id,
|
||||
name_id_format=saml_session.name_id_format,
|
||||
session_index=saml_session.session_index,
|
||||
relay_state=relay_state,
|
||||
)
|
||||
|
||||
# Insert before terminal stages (SessionEndStage, NativeLogoutStageView)
|
||||
# so the SLO redirect runs before the flow ends or the user is
|
||||
# redirected away. Provider logout stages (at index 1/2) still run
|
||||
# first since they're inserted earlier.
|
||||
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
|
||||
redirect_url = processor.get_redirect_url()
|
||||
stage = in_memory_stage(RedirectStage, destination=redirect_url)
|
||||
else:
|
||||
# POST binding
|
||||
form_data = processor.get_post_form_data()
|
||||
executor.plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
|
||||
executor.plan.context[PLAN_CONTEXT_URL] = source.slo_url
|
||||
executor.plan.context[PLAN_CONTEXT_ATTRS] = form_data
|
||||
stage = in_memory_stage(AutosubmitStageView)
|
||||
|
||||
_insert_before_terminal_stage(executor.plan, stage)
|
||||
|
||||
LOGGER.debug(
|
||||
"Injected SAML source SLO into logout flow",
|
||||
source=source.name,
|
||||
binding=source.slo_binding,
|
||||
)
|
||||
|
||||
except (KeyError, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to generate SAML source logout request",
|
||||
source=source.name,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
# Clean up SAMLSourceSessions for this auth session
|
||||
saml_source_sessions.delete()
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""SAML Source stages and flow manager"""
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||
from authentik.core.sources.stage import PostSourceStage
|
||||
from authentik.flows.models import Flow, Stage, in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
|
||||
from authentik.sources.saml.models import (
|
||||
GroupSAMLSourceConnection,
|
||||
SAMLSource,
|
||||
SAMLSourceSession,
|
||||
UserSAMLSourceConnection,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA = "saml_session_data"
|
||||
|
||||
|
||||
class SAMLPostSourceStage(PostSourceStage):
|
||||
"""Extends PostSourceStage to also create SAMLSourceSession for SLO support."""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
response = super().dispatch(request)
|
||||
|
||||
session_data = self.executor.plan.context.get(PLAN_CONTEXT_SAML_SESSION_DATA)
|
||||
if not session_data:
|
||||
return response
|
||||
|
||||
source = self.executor.plan.context.get(PLAN_CONTEXT_SOURCE)
|
||||
if not isinstance(source, SAMLSource):
|
||||
return response
|
||||
|
||||
user: User = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not user or not user.pk:
|
||||
return response
|
||||
|
||||
auth_session = AuthenticatedSession.from_request(request, user)
|
||||
if not auth_session:
|
||||
return response
|
||||
|
||||
SAMLSourceSession.objects.create(
|
||||
source=source,
|
||||
user=user,
|
||||
session=auth_session,
|
||||
session_index=session_data.get("session_index", ""),
|
||||
name_id=session_data.get("name_id", ""),
|
||||
name_id_format=session_data.get("name_id_format", ""),
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Created SAMLSourceSession",
|
||||
source=source.name,
|
||||
user=user,
|
||||
session_index=session_data.get("session_index", ""),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SAMLSourceFlowManager(SourceFlowManager):
|
||||
"""Source flow manager for SAML Sources"""
|
||||
|
||||
user_connection_type = UserSAMLSourceConnection
|
||||
group_connection_type = GroupSAMLSourceConnection
|
||||
|
||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||
return [
|
||||
in_memory_stage(SAMLPostSourceStage),
|
||||
]
|
||||
@@ -3,6 +3,7 @@
|
||||
from urllib.parse import parse_qsl, urlparse, urlunparse
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
@@ -12,13 +13,9 @@ from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
from xmlsec import InternalError, VerificationError
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.common.saml.parsers.logout_response import LogoutResponseParser
|
||||
from authentik.flows.challenge import (
|
||||
PLAN_CONTEXT_ATTRS,
|
||||
PLAN_CONTEXT_TITLE,
|
||||
@@ -36,7 +33,7 @@ from authentik.flows.planner import (
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import ChallengeStageView, RedirectStage, SessionEndStage
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
@@ -47,13 +44,7 @@ from authentik.sources.saml.exceptions import (
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import (
|
||||
SAMLBindingTypes,
|
||||
SAMLSLOBindingTypes,
|
||||
SAMLSource,
|
||||
SAMLSourceSession,
|
||||
)
|
||||
from authentik.sources.saml.processors.logout_response import LogoutResponseBuilder
|
||||
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.sources.saml.processors.request import RequestProcessor
|
||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
||||
@@ -61,8 +52,6 @@ from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentS
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_SAML_RELAY_STATE = "goauthentik.io/sources/saml/relay_state"
|
||||
|
||||
|
||||
class AutosubmitStageView(ChallengeStageView):
|
||||
"""Wrapper stage to create an autosubmit challenge from plan context variables"""
|
||||
@@ -192,195 +181,16 @@ class ACSView(View):
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SLOView(View):
|
||||
"""Single-Logout-View: handles SP-initiated SLO, IdP-initiated LogoutRequest,
|
||||
and LogoutResponse from IdP"""
|
||||
class SLOView(LoginRequiredMixin, View):
|
||||
"""Single-Logout-View"""
|
||||
|
||||
def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Handle GET requests: LogoutResponse, LogoutRequest, or initiate SLO."""
|
||||
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Log user out and redirect them to the IdP's SLO URL."""
|
||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||
if not source.enabled:
|
||||
raise Http404
|
||||
|
||||
if "SAMLResponse" in request.GET:
|
||||
return self._handle_logout_response(
|
||||
request,
|
||||
request.GET["SAMLResponse"],
|
||||
relay_state=request.GET.get("RelayState"),
|
||||
)
|
||||
|
||||
if "SAMLRequest" in request.GET:
|
||||
return self._handle_logout_request(
|
||||
request, source, request.GET["SAMLRequest"], is_post=False
|
||||
)
|
||||
|
||||
# No SAML message, initiate SP-initiated SLO
|
||||
return self._initiate_logout(request)
|
||||
|
||||
def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Handle POST requests: LogoutResponse or LogoutRequest from the IdP."""
|
||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||
if not source.enabled:
|
||||
raise Http404
|
||||
|
||||
if "SAMLResponse" in request.POST:
|
||||
return self._handle_logout_response(
|
||||
request,
|
||||
request.POST["SAMLResponse"],
|
||||
relay_state=request.POST.get("RelayState"),
|
||||
)
|
||||
|
||||
if "SAMLRequest" in request.POST:
|
||||
return self._handle_logout_request(
|
||||
request, source, request.POST["SAMLRequest"], is_post=True
|
||||
)
|
||||
|
||||
return bad_request_message(request, "Missing SAMLRequest or SAMLResponse")
|
||||
|
||||
def _initiate_logout(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Initiate logout using the brand's invalidation flow.
|
||||
|
||||
The invalidation flow contains a UserLogoutStage which fires the
|
||||
flow_pre_user_logout signal. Our signal handler in signals.py picks that up,
|
||||
finds the SAMLSourceSession, and injects the SLO redirect/POST stage."""
|
||||
# Sources do not have an invalidation flow, use the brand's
|
||||
flow = request.brand.flow_invalidation
|
||||
if not flow:
|
||||
logout(request)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(request)
|
||||
except FlowNonApplicableException:
|
||||
logout(request)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
def _handle_logout_request(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
source: SAMLSource,
|
||||
raw_request: str,
|
||||
is_post: bool = False,
|
||||
) -> HttpResponse:
|
||||
"""Handle an incoming LogoutRequest from the IdP (IdP-initiated SLO).
|
||||
|
||||
Parses the request, deletes the SAMLSourceSession (to prevent circular
|
||||
redirect back to the IdP), runs the invalidation flow, and appends a
|
||||
final stage to send the LogoutResponse back to the IdP."""
|
||||
parser = LogoutRequestParser()
|
||||
try:
|
||||
if is_post:
|
||||
logout_request = parser.parse(raw_request)
|
||||
else:
|
||||
logout_request = parser.parse_detached(raw_request)
|
||||
except (CannotHandleAssertion, ValueError) as exc:
|
||||
LOGGER.warning("Failed to parse LogoutRequest from IdP", exc=exc)
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
relay_state = (
|
||||
request.GET.get("RelayState") if not is_post else request.POST.get("RelayState")
|
||||
)
|
||||
|
||||
# Delete SAMLSourceSession so the source signal handler doesn't try to
|
||||
# redirect back to the IdP (which would be circular)
|
||||
SAMLSourceSession.objects.filter(
|
||||
source=source,
|
||||
user=request.user,
|
||||
).delete()
|
||||
|
||||
# Build the LogoutResponse to send back to the IdP after logout
|
||||
response_builder = LogoutResponseBuilder(
|
||||
source=source,
|
||||
http_request=request,
|
||||
destination=source.slo_url,
|
||||
in_response_to=logout_request.id,
|
||||
)
|
||||
|
||||
# Sources do not have an invalidation flow, use the brand's
|
||||
flow = request.brand.flow_invalidation
|
||||
if not flow:
|
||||
logout(request)
|
||||
return self._send_logout_response(response_builder, relay_state)
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(request)
|
||||
except FlowNonApplicableException:
|
||||
logout(request)
|
||||
return self._send_logout_response(response_builder, relay_state)
|
||||
|
||||
# Append logout response stage, then session end
|
||||
self._append_response_stage(plan, source, response_builder, relay_state)
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
def _send_logout_response(
|
||||
self,
|
||||
response_builder: LogoutResponseBuilder,
|
||||
relay_state: str | None = None,
|
||||
) -> HttpResponse:
|
||||
"""Send LogoutResponse back to the IdP directly (no flow).
|
||||
Without a flow we can't render an autosubmit form, so always redirect."""
|
||||
return redirect(response_builder.get_redirect_url(relay_state))
|
||||
|
||||
def _append_response_stage(
|
||||
self,
|
||||
plan: FlowPlan,
|
||||
source: SAMLSource,
|
||||
response_builder: LogoutResponseBuilder,
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
"""Append a stage to send the LogoutResponse back to the IdP."""
|
||||
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
|
||||
redirect_url = response_builder.get_redirect_url(relay_state)
|
||||
plan.append_stage(in_memory_stage(RedirectStage, destination=redirect_url))
|
||||
else:
|
||||
# POST binding — use autosubmit form
|
||||
form_data = response_builder.get_post_form_data(relay_state)
|
||||
plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
|
||||
plan.context[PLAN_CONTEXT_URL] = source.slo_url
|
||||
plan.context[PLAN_CONTEXT_ATTRS] = form_data
|
||||
plan.append_stage(in_memory_stage(AutosubmitStageView))
|
||||
|
||||
def _handle_logout_response(
|
||||
self, request: HttpRequest, raw_response: str, relay_state: str | None = None
|
||||
) -> HttpResponse:
|
||||
"""Parse and handle a LogoutResponse from the IdP."""
|
||||
processor = LogoutResponseParser(raw_response)
|
||||
try:
|
||||
processor.parse()
|
||||
except (ValueError, etree.XMLSyntaxError) as exc:
|
||||
LOGGER.warning("Failed to parse LogoutResponse", exc=exc)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
processor.verify_status()
|
||||
|
||||
# If a RelayState was provided (e.g. the flow executor URL), advance
|
||||
# past the current stage (RedirectStage) in the plan so the flow
|
||||
# continues to the next stage instead of looping. Only redirect to the
|
||||
# value stashed in the plan context on outbound — never to the value
|
||||
# echoed back in the request, which is attacker-controllable.
|
||||
if relay_state and SESSION_KEY_PLAN in request.session:
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
stored_relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE, "")
|
||||
if relay_state != stored_relay_state:
|
||||
LOGGER.warning(
|
||||
"SAML logout relay_state mismatch, possible open redirect attempt",
|
||||
received_relay_state=relay_state,
|
||||
stored_relay_state=stored_relay_state,
|
||||
)
|
||||
if plan.bindings:
|
||||
plan.pop()
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
if stored_relay_state:
|
||||
return redirect(stored_relay_state)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
logout(request)
|
||||
return redirect(source.slo_url)
|
||||
|
||||
|
||||
class MetadataView(View):
|
||||
|
||||
@@ -6049,22 +6049,18 @@
|
||||
"authentik_sources_saml.add_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.add_samlsource",
|
||||
"authentik_sources_saml.add_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.add_samlsourcesession",
|
||||
"authentik_sources_saml.add_usersamlsourceconnection",
|
||||
"authentik_sources_saml.change_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.change_samlsource",
|
||||
"authentik_sources_saml.change_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.change_samlsourcesession",
|
||||
"authentik_sources_saml.change_usersamlsourceconnection",
|
||||
"authentik_sources_saml.delete_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.delete_samlsource",
|
||||
"authentik_sources_saml.delete_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.delete_samlsourcesession",
|
||||
"authentik_sources_saml.delete_usersamlsourceconnection",
|
||||
"authentik_sources_saml.view_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.view_samlsource",
|
||||
"authentik_sources_saml.view_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.view_samlsourcesession",
|
||||
"authentik_sources_saml.view_usersamlsourceconnection",
|
||||
"authentik_sources_scim.add_scimsource",
|
||||
"authentik_sources_scim.add_scimsourcegroup",
|
||||
@@ -11750,22 +11746,18 @@
|
||||
"authentik_sources_saml.add_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.add_samlsource",
|
||||
"authentik_sources_saml.add_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.add_samlsourcesession",
|
||||
"authentik_sources_saml.add_usersamlsourceconnection",
|
||||
"authentik_sources_saml.change_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.change_samlsource",
|
||||
"authentik_sources_saml.change_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.change_samlsourcesession",
|
||||
"authentik_sources_saml.change_usersamlsourceconnection",
|
||||
"authentik_sources_saml.delete_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.delete_samlsource",
|
||||
"authentik_sources_saml.delete_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.delete_samlsourcesession",
|
||||
"authentik_sources_saml.delete_usersamlsourceconnection",
|
||||
"authentik_sources_saml.view_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.view_samlsource",
|
||||
"authentik_sources_saml.view_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.view_samlsourcesession",
|
||||
"authentik_sources_saml.view_usersamlsourceconnection",
|
||||
"authentik_sources_scim.add_scimsource",
|
||||
"authentik_sources_scim.add_scimsourcegroup",
|
||||
@@ -13586,15 +13578,6 @@
|
||||
],
|
||||
"title": "Binding type"
|
||||
},
|
||||
"slo_binding": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REDIRECT",
|
||||
"POST"
|
||||
],
|
||||
"title": "SLO Binding",
|
||||
"description": "Binding type for Single Logout requests to the IdP."
|
||||
},
|
||||
"verification_kp": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
@@ -13651,16 +13634,6 @@
|
||||
"signed_response": {
|
||||
"type": "boolean",
|
||||
"title": "Signed response"
|
||||
},
|
||||
"sign_authn_request": {
|
||||
"type": "boolean",
|
||||
"title": "Sign AuthnRequest",
|
||||
"description": "Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
|
||||
},
|
||||
"sign_logout_request": {
|
||||
"type": "boolean",
|
||||
"title": "Sign LogoutRequest",
|
||||
"description": "Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
2
go.mod
2
go.mod
@@ -7,7 +7,7 @@ require (
|
||||
beryju.io/radius-eap v0.1.0
|
||||
github.com/avast/retry-go/v4 v4.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/getsentry/sentry-go v0.45.1
|
||||
github.com/getsentry/sentry-go v0.46.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-openapi/runtime v0.29.4
|
||||
|
||||
4
go.sum
4
go.sum
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw=
|
||||
github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||
github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
|
||||
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""Wrapper for lifecycle/ak, to be installed by uv"""
|
||||
|
||||
from os import system, waitstatus_to_exitcode
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from sys import argv, exit
|
||||
|
||||
|
||||
def main():
|
||||
"""Wrapper around ak bash script"""
|
||||
current_path = Path(__file__)
|
||||
args = " ".join(argv[1:])
|
||||
res = system(f"{current_path.parent}/ak {args}") # nosec
|
||||
exit_code = waitstatus_to_exitcode(res)
|
||||
exit(exit_code)
|
||||
script = Path(__file__).parent / "ak"
|
||||
res = subprocess.run([str(script), *argv[1:]], check=False)
|
||||
exit(res.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
16
packages/client-ts/src/apis/SourcesApi.ts
generated
16
packages/client-ts/src/apis/SourcesApi.ts
generated
@@ -101,7 +101,6 @@ import type {
|
||||
SCIMSourceUser,
|
||||
SCIMSourceUserRequest,
|
||||
SignatureAlgorithmEnum,
|
||||
SloBindingEnum,
|
||||
Source,
|
||||
SourceType,
|
||||
SyncStatus,
|
||||
@@ -750,13 +749,10 @@ export interface SourcesSamlListRequest {
|
||||
policyEngineMode?: PolicyEngineMode;
|
||||
preAuthenticationFlow?: string;
|
||||
search?: string;
|
||||
signAuthnRequest?: boolean;
|
||||
signLogoutRequest?: boolean;
|
||||
signatureAlgorithm?: SignatureAlgorithmEnum;
|
||||
signedAssertion?: boolean;
|
||||
signedResponse?: boolean;
|
||||
signingKp?: string;
|
||||
sloBinding?: SloBindingEnum;
|
||||
sloUrl?: string;
|
||||
slug?: string;
|
||||
ssoUrl?: string;
|
||||
@@ -7738,14 +7734,6 @@ export class SourcesApi extends runtime.BaseAPI {
|
||||
queryParameters["search"] = requestParameters["search"];
|
||||
}
|
||||
|
||||
if (requestParameters["signAuthnRequest"] != null) {
|
||||
queryParameters["sign_authn_request"] = requestParameters["signAuthnRequest"];
|
||||
}
|
||||
|
||||
if (requestParameters["signLogoutRequest"] != null) {
|
||||
queryParameters["sign_logout_request"] = requestParameters["signLogoutRequest"];
|
||||
}
|
||||
|
||||
if (requestParameters["signatureAlgorithm"] != null) {
|
||||
queryParameters["signature_algorithm"] = requestParameters["signatureAlgorithm"];
|
||||
}
|
||||
@@ -7762,10 +7750,6 @@ export class SourcesApi extends runtime.BaseAPI {
|
||||
queryParameters["signing_kp"] = requestParameters["signingKp"];
|
||||
}
|
||||
|
||||
if (requestParameters["sloBinding"] != null) {
|
||||
queryParameters["slo_binding"] = requestParameters["sloBinding"];
|
||||
}
|
||||
|
||||
if (requestParameters["sloUrl"] != null) {
|
||||
queryParameters["slo_url"] = requestParameters["sloUrl"];
|
||||
}
|
||||
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
SignatureAlgorithmEnumToJSON,
|
||||
} from "./SignatureAlgorithmEnum";
|
||||
import type { SloBindingEnum } from "./SloBindingEnum";
|
||||
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
|
||||
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
|
||||
import { UserMatchingModeEnumFromJSON, UserMatchingModeEnumToJSON } from "./UserMatchingModeEnum";
|
||||
|
||||
@@ -167,12 +165,6 @@ export interface PatchedSAMLSourceRequest {
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
bindingType?: BindingTypeEnum;
|
||||
/**
|
||||
* Binding type for Single Logout requests to the IdP.
|
||||
* @type {SloBindingEnum}
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
sloBinding?: SloBindingEnum;
|
||||
/**
|
||||
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
|
||||
* @type {string}
|
||||
@@ -221,18 +213,6 @@ export interface PatchedSAMLSourceRequest {
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
signedResponse?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
signAuthnRequest?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
signLogoutRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,8 +278,6 @@ export function PatchedSAMLSourceRequestFromJSONTyped(
|
||||
json["binding_type"] == null
|
||||
? undefined
|
||||
: BindingTypeEnumFromJSON(json["binding_type"]),
|
||||
sloBinding:
|
||||
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
|
||||
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
|
||||
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
|
||||
digestAlgorithm:
|
||||
@@ -317,10 +295,6 @@ export function PatchedSAMLSourceRequestFromJSONTyped(
|
||||
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
|
||||
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
|
||||
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
|
||||
signAuthnRequest:
|
||||
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
|
||||
signLogoutRequest:
|
||||
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,7 +332,6 @@ export function PatchedSAMLSourceRequestToJSONTyped(
|
||||
force_authn: value["forceAuthn"],
|
||||
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
|
||||
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
|
||||
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
|
||||
verification_kp: value["verificationKp"],
|
||||
signing_kp: value["signingKp"],
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
@@ -367,7 +340,5 @@ export function PatchedSAMLSourceRequestToJSONTyped(
|
||||
encryption_kp: value["encryptionKp"],
|
||||
signed_assertion: value["signedAssertion"],
|
||||
signed_response: value["signedResponse"],
|
||||
sign_authn_request: value["signAuthnRequest"],
|
||||
sign_logout_request: value["signLogoutRequest"],
|
||||
};
|
||||
}
|
||||
|
||||
29
packages/client-ts/src/models/SAMLSource.ts
generated
29
packages/client-ts/src/models/SAMLSource.ts
generated
@@ -30,8 +30,6 @@ import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
SignatureAlgorithmEnumToJSON,
|
||||
} from "./SignatureAlgorithmEnum";
|
||||
import type { SloBindingEnum } from "./SloBindingEnum";
|
||||
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
|
||||
import type { ThemedUrls } from "./ThemedUrls";
|
||||
import { ThemedUrlsFromJSON } from "./ThemedUrls";
|
||||
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
|
||||
@@ -217,12 +215,6 @@ export interface SAMLSource {
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
bindingType?: BindingTypeEnum;
|
||||
/**
|
||||
* Binding type for Single Logout requests to the IdP.
|
||||
* @type {SloBindingEnum}
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
sloBinding?: SloBindingEnum;
|
||||
/**
|
||||
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
|
||||
* @type {string}
|
||||
@@ -271,18 +263,6 @@ export interface SAMLSource {
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
signedResponse?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
signAuthnRequest?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
signLogoutRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,8 +343,6 @@ export function SAMLSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
|
||||
json["binding_type"] == null
|
||||
? undefined
|
||||
: BindingTypeEnumFromJSON(json["binding_type"]),
|
||||
sloBinding:
|
||||
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
|
||||
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
|
||||
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
|
||||
digestAlgorithm:
|
||||
@@ -382,10 +360,6 @@ export function SAMLSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
|
||||
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
|
||||
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
|
||||
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
|
||||
signAuthnRequest:
|
||||
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
|
||||
signLogoutRequest:
|
||||
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,7 +407,6 @@ export function SAMLSourceToJSONTyped(
|
||||
force_authn: value["forceAuthn"],
|
||||
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
|
||||
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
|
||||
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
|
||||
verification_kp: value["verificationKp"],
|
||||
signing_kp: value["signingKp"],
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
@@ -442,7 +415,5 @@ export function SAMLSourceToJSONTyped(
|
||||
encryption_kp: value["encryptionKp"],
|
||||
signed_assertion: value["signedAssertion"],
|
||||
signed_response: value["signedResponse"],
|
||||
sign_authn_request: value["signAuthnRequest"],
|
||||
sign_logout_request: value["signLogoutRequest"],
|
||||
};
|
||||
}
|
||||
|
||||
29
packages/client-ts/src/models/SAMLSourceRequest.ts
generated
29
packages/client-ts/src/models/SAMLSourceRequest.ts
generated
@@ -30,8 +30,6 @@ import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
SignatureAlgorithmEnumToJSON,
|
||||
} from "./SignatureAlgorithmEnum";
|
||||
import type { SloBindingEnum } from "./SloBindingEnum";
|
||||
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
|
||||
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
|
||||
import { UserMatchingModeEnumFromJSON, UserMatchingModeEnumToJSON } from "./UserMatchingModeEnum";
|
||||
|
||||
@@ -167,12 +165,6 @@ export interface SAMLSourceRequest {
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
bindingType?: BindingTypeEnum;
|
||||
/**
|
||||
* Binding type for Single Logout requests to the IdP.
|
||||
* @type {SloBindingEnum}
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
sloBinding?: SloBindingEnum;
|
||||
/**
|
||||
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
|
||||
* @type {string}
|
||||
@@ -221,18 +213,6 @@ export interface SAMLSourceRequest {
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
signedResponse?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
signAuthnRequest?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
signLogoutRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,8 +280,6 @@ export function SAMLSourceRequestFromJSONTyped(
|
||||
json["binding_type"] == null
|
||||
? undefined
|
||||
: BindingTypeEnumFromJSON(json["binding_type"]),
|
||||
sloBinding:
|
||||
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
|
||||
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
|
||||
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
|
||||
digestAlgorithm:
|
||||
@@ -319,10 +297,6 @@ export function SAMLSourceRequestFromJSONTyped(
|
||||
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
|
||||
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
|
||||
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
|
||||
signAuthnRequest:
|
||||
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
|
||||
signLogoutRequest:
|
||||
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -360,7 +334,6 @@ export function SAMLSourceRequestToJSONTyped(
|
||||
force_authn: value["forceAuthn"],
|
||||
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
|
||||
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
|
||||
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
|
||||
verification_kp: value["verificationKp"],
|
||||
signing_kp: value["signingKp"],
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
@@ -369,7 +342,5 @@ export function SAMLSourceRequestToJSONTyped(
|
||||
encryption_kp: value["encryptionKp"],
|
||||
signed_assertion: value["signedAssertion"],
|
||||
signed_response: value["signedResponse"],
|
||||
sign_authn_request: value["signAuthnRequest"],
|
||||
sign_logout_request: value["signLogoutRequest"],
|
||||
};
|
||||
}
|
||||
|
||||
57
packages/client-ts/src/models/SloBindingEnum.ts
generated
57
packages/client-ts/src/models/SloBindingEnum.ts
generated
@@ -1,57 +0,0 @@
|
||||
/* 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 SloBindingEnum = {
|
||||
Redirect: "REDIRECT",
|
||||
Post: "POST",
|
||||
UnknownDefaultOpenApi: "11184809",
|
||||
} as const;
|
||||
export type SloBindingEnum = (typeof SloBindingEnum)[keyof typeof SloBindingEnum];
|
||||
|
||||
export function instanceOfSloBindingEnum(value: any): boolean {
|
||||
for (const key in SloBindingEnum) {
|
||||
if (Object.prototype.hasOwnProperty.call(SloBindingEnum, key)) {
|
||||
if (SloBindingEnum[key as keyof typeof SloBindingEnum] === value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function SloBindingEnumFromJSON(json: any): SloBindingEnum {
|
||||
return SloBindingEnumFromJSONTyped(json, false);
|
||||
}
|
||||
|
||||
export function SloBindingEnumFromJSONTyped(
|
||||
json: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): SloBindingEnum {
|
||||
return json as SloBindingEnum;
|
||||
}
|
||||
|
||||
export function SloBindingEnumToJSON(value?: SloBindingEnum | null): any {
|
||||
return value as any;
|
||||
}
|
||||
|
||||
export function SloBindingEnumToJSONTyped(
|
||||
value: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): SloBindingEnum {
|
||||
return value as SloBindingEnum;
|
||||
}
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -766,7 +766,6 @@ export * from "./SettingsRequest";
|
||||
export * from "./SeverityEnum";
|
||||
export * from "./ShellChallenge";
|
||||
export * from "./SignatureAlgorithmEnum";
|
||||
export * from "./SloBindingEnum";
|
||||
export * from "./Software";
|
||||
export * from "./SoftwareRequest";
|
||||
export * from "./Source";
|
||||
|
||||
@@ -66,7 +66,7 @@ dependencies = [
|
||||
"ua-parser==1.0.2",
|
||||
"unidecode==1.4.0",
|
||||
"urllib3<3",
|
||||
"uvicorn[standard]==0.44.0",
|
||||
"uvicorn[standard]==0.45.0",
|
||||
"watchdog==6.0.0",
|
||||
"webauthn==2.7.1",
|
||||
"wsproto==1.3.2",
|
||||
@@ -192,7 +192,6 @@ ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"authentik.common.*",
|
||||
"authentik.admin.*",
|
||||
"authentik.api.*",
|
||||
"authentik.blueprints.*",
|
||||
|
||||
63
schema.yml
63
schema.yml
@@ -24369,14 +24369,6 @@ paths:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: '#/components/parameters/QuerySearch'
|
||||
- in: query
|
||||
name: sign_authn_request
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: sign_logout_request
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: signature_algorithm
|
||||
schema:
|
||||
@@ -24394,14 +24386,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: slo_binding
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: |+
|
||||
Binding type for Single Logout requests to the IdP.
|
||||
|
||||
- in: query
|
||||
name: slo_url
|
||||
schema:
|
||||
@@ -50452,10 +50436,6 @@ components:
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
$ref: '#/components/schemas/BindingTypeEnum'
|
||||
slo_binding:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: Binding type for Single Logout requests to the IdP.
|
||||
verification_kp:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -50493,16 +50473,6 @@ components:
|
||||
type: boolean
|
||||
signed_response:
|
||||
type: boolean
|
||||
sign_authn_request:
|
||||
type: boolean
|
||||
title: Sign AuthnRequest
|
||||
description: Whether to sign outgoing AuthnRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
sign_logout_request:
|
||||
type: boolean
|
||||
title: Sign LogoutRequest
|
||||
description: Whether to sign outgoing LogoutRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
PatchedSCIMMappingRequest:
|
||||
type: object
|
||||
description: SCIMMapping Serializer
|
||||
@@ -54206,10 +54176,6 @@ components:
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
$ref: '#/components/schemas/BindingTypeEnum'
|
||||
slo_binding:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: Binding type for Single Logout requests to the IdP.
|
||||
verification_kp:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -54246,16 +54212,6 @@ components:
|
||||
type: boolean
|
||||
signed_response:
|
||||
type: boolean
|
||||
sign_authn_request:
|
||||
type: boolean
|
||||
title: Sign AuthnRequest
|
||||
description: Whether to sign outgoing AuthnRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
sign_logout_request:
|
||||
type: boolean
|
||||
title: Sign LogoutRequest
|
||||
description: Whether to sign outgoing LogoutRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
required:
|
||||
- component
|
||||
- icon_themed_urls
|
||||
@@ -54424,10 +54380,6 @@ components:
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
$ref: '#/components/schemas/BindingTypeEnum'
|
||||
slo_binding:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: Binding type for Single Logout requests to the IdP.
|
||||
verification_kp:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -54465,16 +54417,6 @@ components:
|
||||
type: boolean
|
||||
signed_response:
|
||||
type: boolean
|
||||
sign_authn_request:
|
||||
type: boolean
|
||||
title: Sign AuthnRequest
|
||||
description: Whether to sign outgoing AuthnRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
sign_logout_request:
|
||||
type: boolean
|
||||
title: Sign LogoutRequest
|
||||
description: Whether to sign outgoing LogoutRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
required:
|
||||
- name
|
||||
- pre_authentication_flow
|
||||
@@ -55735,11 +55677,6 @@ components:
|
||||
- http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512
|
||||
- http://www.w3.org/2000/09/xmldsig#dsa-sha1
|
||||
type: string
|
||||
SloBindingEnum:
|
||||
enum:
|
||||
- REDIRECT
|
||||
- POST
|
||||
type: string
|
||||
Software:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -375,7 +375,7 @@ requires-dist = [
|
||||
{ name = "ua-parser", specifier = "==1.0.2" },
|
||||
{ name = "unidecode", specifier = "==1.4.0" },
|
||||
{ name = "urllib3", specifier = "<3" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.44.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.45.0" },
|
||||
{ name = "watchdog", specifier = "==6.0.0" },
|
||||
{ name = "webauthn", specifier = "==2.7.1" },
|
||||
{ name = "wsproto", specifier = "==1.3.2" },
|
||||
@@ -3808,15 +3808,15 @@ socks = [
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
version = "0.45.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
@@ -77,6 +77,8 @@ export class FormFixture extends PageFixture {
|
||||
|
||||
/**
|
||||
* Search for a row containing the given text.
|
||||
*
|
||||
* @returns A locator for the row entry matching the query.
|
||||
*/
|
||||
public search = async (
|
||||
query: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
|
||||
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
export const GOOD_USERNAME = "test-admin@goauthentik.io";
|
||||
export const GOOD_PASSWORD = "test-runner";
|
||||
|
||||
@@ -11,6 +13,8 @@ export interface LoginInit {
|
||||
username?: string;
|
||||
password?: string;
|
||||
to?: URL | string;
|
||||
rememberMe?: boolean;
|
||||
page?: Page;
|
||||
}
|
||||
|
||||
export interface SessionFixtureInit extends PageFixtureInit {
|
||||
@@ -36,6 +40,10 @@ export class SessionFixture extends PageFixture {
|
||||
public $passwordStage = this.page.locator("ak-stage-password");
|
||||
public $passwordField = this.page.getByLabel("Password");
|
||||
|
||||
public $rememberMeCheckbox = this.page.getByRole("checkbox", {
|
||||
name: "Remember me on this device",
|
||||
});
|
||||
|
||||
/**
|
||||
* The button to submit the the login flow,
|
||||
* typically redirecting to the authenticated interface.
|
||||
@@ -66,19 +74,45 @@ export class SessionFixture extends PageFixture {
|
||||
/**
|
||||
* Log into the application.
|
||||
*/
|
||||
public async login({
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
}: LoginInit = {}) {
|
||||
public async login(
|
||||
{
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
rememberMe,
|
||||
}: LoginInit = {},
|
||||
page = this.page,
|
||||
): Promise<void> {
|
||||
this.logger.info("Logging in...");
|
||||
|
||||
const initialURL = new URL(this.page.url());
|
||||
const initialURL = new URL(page.url());
|
||||
|
||||
if (initialURL.pathname === SessionFixture.pathname) {
|
||||
this.logger.info("Skipping navigation because we're already in a authentication flow");
|
||||
} else {
|
||||
await this.page.goto(to.toString());
|
||||
await page.goto(to.toString());
|
||||
}
|
||||
|
||||
if (typeof rememberMe === "boolean") {
|
||||
const rememberMeCheckboxVisible = await this.$rememberMeCheckbox.isVisible();
|
||||
|
||||
if (rememberMeCheckboxVisible) {
|
||||
if (rememberMe) {
|
||||
await this.$rememberMeCheckbox.check();
|
||||
|
||||
await expect(
|
||||
this.$rememberMeCheckbox,
|
||||
"Remember me checkbox is checked",
|
||||
).toBeChecked();
|
||||
} else {
|
||||
await this.$rememberMeCheckbox.uncheck();
|
||||
|
||||
await expect(
|
||||
this.$rememberMeCheckbox,
|
||||
"Remember me checkbox is unchecked",
|
||||
).not.toBeChecked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.$usernameField.fill(username);
|
||||
@@ -102,7 +136,7 @@ export class SessionFixture extends PageFixture {
|
||||
|
||||
//#region Navigation
|
||||
|
||||
public async toLoginPage() {
|
||||
await this.page.goto(SessionFixture.pathname);
|
||||
public async toLoginPage(page: Page = this.page) {
|
||||
await page.goto(SessionFixture.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
225
web/package-lock.json
generated
225
web/package-lock.json
generated
@@ -78,7 +78,7 @@
|
||||
"globals": "^17.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"hastscript": "^9.0.1",
|
||||
"knip": "^6.4.1",
|
||||
"knip": "^6.6.0",
|
||||
"lex": "^2025.11.0",
|
||||
"lit": "^3.3.2",
|
||||
"lit-analyzer": "^2.0.3",
|
||||
@@ -2141,9 +2141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2224,9 +2224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm-eabi": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.121.0.tgz",
|
||||
"integrity": "sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.126.0.tgz",
|
||||
"integrity": "sha512-svyoHt25J4741QJ5aa4R+h0iiBeSRt63Lr3aAZcxy2c/NeSE1IfDeMnSij6rIg7EjxkdlXzz613wUjeCeilBNA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2240,9 +2240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm64": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.121.0.tgz",
|
||||
"integrity": "sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.126.0.tgz",
|
||||
"integrity": "sha512-hPEBRKgplp1mG9GkINFsr4JVMDNrGJLOqfDaadTWpAoTnzYR5Rmv8RMvB3hJZpiNvbk1aacopdHUP1pggMQ/cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2256,9 +2256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-arm64": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.121.0.tgz",
|
||||
"integrity": "sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.126.0.tgz",
|
||||
"integrity": "sha512-ccRpu9sdYmznePJQG5halhs0FW5tw5a8zRSoZXOzM1OjoeZ4jiRRruFiPclsD59edoVAK1l83dvfjWz1nQi6lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2272,9 +2272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-x64": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.121.0.tgz",
|
||||
"integrity": "sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.126.0.tgz",
|
||||
"integrity": "sha512-CHB4zVjNSKqx8Fw9pHowzQQnjjuq04i4Ng0Avj+DixlwhwAoMYqlFbocYIlbg+q3zOLGlm7vEHm83jqEMitnyg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2288,9 +2288,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-freebsd-x64": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.121.0.tgz",
|
||||
"integrity": "sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.126.0.tgz",
|
||||
"integrity": "sha512-RQ3nEJdcDKBfBjmLJ3Vl1d0KQERPV1P8eUrnBm7+VTYyoaJSPLVFuPg1mlD1hk3n0/879VLFMfusFkBal4ssWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2304,9 +2304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.121.0.tgz",
|
||||
"integrity": "sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.126.0.tgz",
|
||||
"integrity": "sha512-onipc2wCDA7Bauzb4KK1mab0GsEDf4ujiIfWECdnmY/2LlzAoX3xdQRLAUyEDB1kn3yilHBrkmXDdHluyHXxiw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2320,9 +2320,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.121.0.tgz",
|
||||
"integrity": "sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.126.0.tgz",
|
||||
"integrity": "sha512-5BuJJPohrV5NJ8lmcYOMbfRCUGoYH5J9HZHeuqOLwkHXWAuPMN3X1h8bC/2mWjmosdbfTtmyIdX3spS/TkqKNg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2336,9 +2336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.121.0.tgz",
|
||||
"integrity": "sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.126.0.tgz",
|
||||
"integrity": "sha512-r2KApRgm2pOJaduRm6GOT8x0whcr67AyejNkSdzPt34GJ+Y3axcXN2mwlTs+8lfO/SSmpO5ZJGYiHYnxEE0jkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2352,9 +2352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.121.0.tgz",
|
||||
"integrity": "sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.126.0.tgz",
|
||||
"integrity": "sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2368,9 +2368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.121.0.tgz",
|
||||
"integrity": "sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.126.0.tgz",
|
||||
"integrity": "sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2384,9 +2384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.121.0.tgz",
|
||||
"integrity": "sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.126.0.tgz",
|
||||
"integrity": "sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2400,9 +2400,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.121.0.tgz",
|
||||
"integrity": "sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.126.0.tgz",
|
||||
"integrity": "sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2416,9 +2416,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.121.0.tgz",
|
||||
"integrity": "sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.126.0.tgz",
|
||||
"integrity": "sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2432,9 +2432,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.121.0.tgz",
|
||||
"integrity": "sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.126.0.tgz",
|
||||
"integrity": "sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2448,9 +2448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-musl": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.121.0.tgz",
|
||||
"integrity": "sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.126.0.tgz",
|
||||
"integrity": "sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2464,9 +2464,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-openharmony-arm64": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.121.0.tgz",
|
||||
"integrity": "sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.126.0.tgz",
|
||||
"integrity": "sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2480,25 +2480,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-wasm32-wasi": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.121.0.tgz",
|
||||
"integrity": "sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.126.0.tgz",
|
||||
"integrity": "sha512-Y17hhnrQTrxgAxAyAq401vnN9URsAL4s5AjqpG1NDsXSlhe1yBNnns+rC2P6xcMoitgX5nKH2ryYt9oiFRlzLw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.121.0.tgz",
|
||||
"integrity": "sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.126.0.tgz",
|
||||
"integrity": "sha512-Znug1u1iRvT4VC3jANz6nhGBHsFwEFMxuimYpJFwMtsB6H5FcEoZRMmH26tHkSTD03JvDmG+gB65W3ajLjPcSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2512,9 +2514,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.121.0.tgz",
|
||||
"integrity": "sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.126.0.tgz",
|
||||
"integrity": "sha512-qrw7mx5hFFTxVSXToOA40hpnjgNB/DJprZchtB4rDKNLKqkD3F26HbzaQeH1nxAKej0efSZfJd5Sw3qdtOLGhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2528,9 +2530,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.121.0.tgz",
|
||||
"integrity": "sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.126.0.tgz",
|
||||
"integrity": "sha512-ibB1s+mPUFXvS7MFJO2jpw/aCNs/P6ifnWlRyTYB+WYBpniOiCcHQQskZneJtwcjQMDRol3RGG3ihoYnzXSY4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2544,9 +2546,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.121.0.tgz",
|
||||
"integrity": "sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
|
||||
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@@ -10004,9 +10006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
@@ -11567,9 +11569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.4.1.tgz",
|
||||
"integrity": "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==",
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.6.0.tgz",
|
||||
"integrity": "sha512-IT1YDiHyRctYYsuZNBd/ZiGoa7HmCaxs+ZrWxCfYjQKPG6QyRqMfkteqC+rBuMymBJeLXyBnRa7hn95O+sGG8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11582,18 +11584,17 @@
|
||||
],
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fdir": "^6.5.0",
|
||||
"formatly": "^0.3.0",
|
||||
"get-tsconfig": "4.13.7",
|
||||
"get-tsconfig": "4.14.0",
|
||||
"jiti": "^2.6.0",
|
||||
"minimist": "^1.2.8",
|
||||
"oxc-parser": "^0.121.0",
|
||||
"oxc-parser": "^0.126.0",
|
||||
"oxc-resolver": "^11.19.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"strip-json-comments": "5.0.3",
|
||||
"tinyglobby": "^0.2.16",
|
||||
"unbash": "^2.2.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.11"
|
||||
@@ -11618,6 +11619,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/knip/node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
@@ -14266,12 +14283,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser": {
|
||||
"version": "0.121.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.121.0.tgz",
|
||||
"integrity": "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==",
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.126.0.tgz",
|
||||
"integrity": "sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.121.0"
|
||||
"@oxc-project/types": "^0.126.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -14280,26 +14297,26 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.121.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.121.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.121.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.121.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.121.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.121.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.121.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.121.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.121.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.121.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.121.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.121.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.121.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.121.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.121.0",
|
||||
"@oxc-parser/binding-wasm32-wasi": "0.121.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.121.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.121.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.121.0"
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.126.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.126.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.126.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.126.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.126.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.126.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.126.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.126.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.126.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.126.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.126.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.126.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.126.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.126.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.126.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.126.0",
|
||||
"@oxc-parser/binding-wasm32-wasi": "0.126.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.126.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.126.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.126.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-resolver": {
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
"globals": "^17.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"hastscript": "^9.0.1",
|
||||
"knip": "^6.4.1",
|
||||
"knip": "^6.6.0",
|
||||
"lex": "^2025.11.0",
|
||||
"lit": "^3.3.2",
|
||||
"lit-analyzer": "^2.0.3",
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
SAMLNameIDPolicyEnum,
|
||||
SAMLSource,
|
||||
SignatureAlgorithmEnum,
|
||||
SloBindingEnum,
|
||||
SourcesApi,
|
||||
UsageEnum,
|
||||
UserMatchingModeEnum,
|
||||
@@ -227,32 +226,6 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
|
||||
${msg("Optional URL if the IDP supports Single-Logout.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("SLO Binding")}
|
||||
required
|
||||
name="sloBinding"
|
||||
>
|
||||
<ak-radio
|
||||
.options=${[
|
||||
{
|
||||
label: msg("Redirect binding"),
|
||||
value: SloBindingEnum.Redirect,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
label: msg("Post binding"),
|
||||
value: SloBindingEnum.Post,
|
||||
},
|
||||
]}
|
||||
.value=${this.instance?.sloBinding}
|
||||
>
|
||||
</ak-radio>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Binding type used for sending Single Logout requests to the IdP.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Issuer")} name="issuer">
|
||||
<input
|
||||
type="text"
|
||||
@@ -301,22 +274,6 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
name="signAuthnRequest"
|
||||
label=${msg("Sign AuthnRequest")}
|
||||
?checked=${this.instance?.signAuthnRequest ?? false}
|
||||
help=${msg(
|
||||
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
|
||||
)}
|
||||
></ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="signLogoutRequest"
|
||||
label=${msg("Sign LogoutRequest")}
|
||||
?checked=${this.instance?.signLogoutRequest ?? false}
|
||||
help=${msg(
|
||||
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
|
||||
)}
|
||||
></ak-switch-input>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Verification Certificate")}
|
||||
name="verificationKp"
|
||||
|
||||
@@ -2,20 +2,30 @@ import "#elements/buttons/SpinnerButton/index";
|
||||
import "#elements/forms/HorizontalFormElement";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import { Form } from "#elements/forms/Form";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { FocusTarget } from "#elements/utils/focus";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@customElement("ak-user-password-form")
|
||||
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
public override submitLabel = msg("Set Password");
|
||||
public static shadowRootOptions: ShadowRootInit = {
|
||||
...Form.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public static override verboseName = msg("Password");
|
||||
public static override verboseNamePlural = msg("Passwords");
|
||||
public static override submittingVerb = msg("Setting");
|
||||
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
|
||||
@@ -23,6 +33,9 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
|
||||
//#region Properties
|
||||
|
||||
public override submitLabel = msg("Set Password");
|
||||
public override successMessage = msg("Successfully updated password.");
|
||||
|
||||
@property({ type: Number })
|
||||
public instancePk?: number;
|
||||
|
||||
@@ -30,13 +43,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
public label = msg("New Password");
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder = msg("New Password");
|
||||
public placeholder = msg("Type a new password...");
|
||||
|
||||
@property({ type: String })
|
||||
public username?: string;
|
||||
@property({ type: String, useDefault: true })
|
||||
public username: string | null = null;
|
||||
|
||||
@property({ type: String })
|
||||
public email?: string;
|
||||
@property({ type: String, useDefault: true })
|
||||
public email: string | null = null;
|
||||
|
||||
public override size = PFSize.Medium;
|
||||
|
||||
/**
|
||||
* The autocomplete attribute to use for the password field.
|
||||
@@ -50,17 +65,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
|
||||
//#endregion
|
||||
|
||||
public override getSuccessMessage(): string {
|
||||
return msg("Successfully updated password.");
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
}
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.focus();
|
||||
requestAnimationFrame(() => {
|
||||
this.focus();
|
||||
});
|
||||
}
|
||||
|
||||
protected override async send(data: UserPasswordSetRequest): Promise<void> {
|
||||
@@ -94,17 +107,26 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
/>`
|
||||
: nothing}
|
||||
|
||||
<ak-form-element-horizontal label=${this.label} required name="password">
|
||||
<ak-form-element-horizontal required name="password">
|
||||
${AKLabel(
|
||||
{
|
||||
slot: "label",
|
||||
className: "pf-c-form__group-label",
|
||||
htmlFor: "password",
|
||||
required: true,
|
||||
},
|
||||
this.label,
|
||||
)}
|
||||
<input
|
||||
autofocus
|
||||
${this.autofocusTarget.toRef()}
|
||||
id="password"
|
||||
type="password"
|
||||
value=""
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder || this.label)}
|
||||
aria-label=${this.label}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
placeholder=${ifPresent(this.placeholder || this.label)}
|
||||
autocomplete=${ifPresent(this.autocomplete)}
|
||||
/>
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
|
||||
140
web/src/common/storage.ts
Normal file
140
web/src/common/storage.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file Storage utilities.
|
||||
*/
|
||||
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
/**
|
||||
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
|
||||
*/
|
||||
export class StorageAccessor {
|
||||
constructor(
|
||||
/**
|
||||
* The key under which the value is stored in the storage backend.
|
||||
*/
|
||||
public readonly key: string,
|
||||
/**
|
||||
* The storage backend to use, e.g. `window.localStorage` or `window.sessionStorage`.
|
||||
*/
|
||||
protected readonly storage: Storage,
|
||||
protected logger = ConsoleLogger.prefix("storage-accessor"),
|
||||
) {
|
||||
if (typeof key !== "string") {
|
||||
throw new TypeError("Storage key must be a string");
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new TypeError("Storage key must be a non-empty string");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link StorageAccessor} for local storage.
|
||||
*
|
||||
* @param key The key under which the value is stored in localStorage.
|
||||
*/
|
||||
public static local = (key: string) => new StorageAccessor(key, self.localStorage);
|
||||
/**
|
||||
* Create a {@link StorageAccessor} for session storage.
|
||||
*
|
||||
* @param key The key under which the value is stored in sessionStorage.
|
||||
*/
|
||||
public static session = (key: string) => new StorageAccessor(key, self.sessionStorage);
|
||||
|
||||
/**
|
||||
* Read the value from storage.
|
||||
*
|
||||
* @param fallback An optional value to return if the key does not exist or an error occurs. Defaults to `null`.
|
||||
*
|
||||
* @returns The stored value, or `null` if the key does not exist or an error occurs.
|
||||
*/
|
||||
public read<T extends string>(fallback?: T): T | null {
|
||||
try {
|
||||
const value = this.storage.getItem(this.key);
|
||||
return value !== null ? (value as T) : (fallback ?? null);
|
||||
} catch (_error: unknown) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to storage.
|
||||
*
|
||||
* @param value The value to store.
|
||||
*
|
||||
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
|
||||
*/
|
||||
public write(value: string | null): boolean {
|
||||
if (!value) {
|
||||
if (this.read()) {
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.storage.setItem(this.key, value);
|
||||
return true;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from storage and parse it as JSON.
|
||||
*
|
||||
* @param fallback An optional value to return if the key does not exist, the value is not valid JSON, or an error occurs. Defaults to `null`.
|
||||
*
|
||||
* @returns The parsed value, or `null` if the key does not exist, the value is not valid JSON, or an error occurs.
|
||||
*/
|
||||
public readJSON<T>(fallback?: T): T | null {
|
||||
const value = this.read<string>();
|
||||
|
||||
if (value === null) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (_error: unknown) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to storage after stringifying it as JSON.
|
||||
*
|
||||
* @param value The value to store.
|
||||
*
|
||||
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
|
||||
*/
|
||||
public writeJSON(value: unknown): boolean {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
return this.write(stringified);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to write JSON value to storage", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the value from storage.
|
||||
*
|
||||
* @returns `true` if the value was successfully deleted, or `false` if an error occurred.
|
||||
*/
|
||||
public delete(): boolean {
|
||||
this.logger.debug("Deleting value from storage");
|
||||
|
||||
try {
|
||||
this.storage.removeItem(this.key);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to delete value from storage", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
|
||||
<a
|
||||
href="${globalAK().api.base}flows/-/default/invalidation/"
|
||||
class="pf-c-button pf-m-plain"
|
||||
aria-label=${msg("Sign out")}
|
||||
>
|
||||
<pf-tooltip position="top" content=${msg("Sign out")}>
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
|
||||
@@ -414,6 +414,13 @@ export class Form<T = Record<string, unknown>, D = T>
|
||||
|
||||
const { submittingVerb, verboseName } = this.constructor as typeof Form;
|
||||
|
||||
if (!verboseName) {
|
||||
return msg(str`${submittingVerb}...`, {
|
||||
id: "form.submitting.no-entity",
|
||||
desc: "The message shown while a form is being submitted, when no entity name is provided.",
|
||||
});
|
||||
}
|
||||
|
||||
return msg(str`${submittingVerb} ${verboseName}...`, {
|
||||
id: "form.submitting",
|
||||
desc: "The message shown while a form is being submitted.",
|
||||
@@ -615,6 +622,7 @@ export class Form<T = Record<string, unknown>, D = T>
|
||||
protected doSubmit = (event: SubmitEvent): void => {
|
||||
if (this.submitting) {
|
||||
this.logger.info("Skipping submit. Already submitting!");
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
@@ -4,6 +4,44 @@
|
||||
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
|
||||
export interface FocusErrorOptions extends ErrorOptions {
|
||||
target: Element | null;
|
||||
}
|
||||
|
||||
export class FocusAssertionError extends Error {
|
||||
public override name = "FocusAssertionError";
|
||||
public readonly target: Element | null;
|
||||
|
||||
constructor(message: string, { target, ...options }: FocusErrorOptions) {
|
||||
super(message, options);
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertFocusable(target: Element | null | undefined): asserts target is HTMLElement {
|
||||
if (!target) {
|
||||
throw new FocusAssertionError("Skipping focus, no target", { target: null });
|
||||
}
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new FocusAssertionError("Skipping focus, target is not an HTMLElement", { target });
|
||||
}
|
||||
|
||||
if (document.activeElement === target) {
|
||||
throw new FocusAssertionError("Target is already focused", { target });
|
||||
}
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
if (!visible) {
|
||||
throw new FocusAssertionError("Skipping focus, target is not visible", { target });
|
||||
}
|
||||
|
||||
if (typeof target.focus !== "function") {
|
||||
throw new FocusAssertionError("Skipping focus, target has no focus method", { target });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Recursively check if the target element or any of its children are active (i.e. "focused").
|
||||
*
|
||||
@@ -36,35 +74,17 @@ export function isActiveElement(
|
||||
* @category DOM
|
||||
*/
|
||||
export function isFocusable(target: Element | null | undefined): target is HTMLElement {
|
||||
if (!target) {
|
||||
console.debug("FocusTarget: Skipping focus, no target", target);
|
||||
try {
|
||||
assertFocusable(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof FocusAssertionError) {
|
||||
console.debug(error.message, error.target);
|
||||
} else {
|
||||
console.error("Unexpected error during focus assertion", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
console.debug("FocusTarget: Skipping focus, target is not an HTMLElement", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.activeElement === target) {
|
||||
console.debug("FocusTarget: Target is already focused", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
if (!visible) {
|
||||
console.debug("FocusTarget: Skipping focus, target is not visible", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof target.focus !== "function") {
|
||||
console.debug("FocusTarget: Skipping focus, target has no focus method", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isDefaultAvatar } from "#elements/utils/images";
|
||||
|
||||
import Styles from "#flow/FormStatic.css";
|
||||
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import { StageChallengeLike } from "#flow/types";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@@ -69,7 +70,9 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
|
||||
${flowInfo?.cancelUrl
|
||||
? html`
|
||||
<div slot="link">
|
||||
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
|
||||
<a href=${flowInfo.cancelUrl} @click=${RememberMeStorage.reset}
|
||||
>${msg("Not you?")}</a
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -121,9 +121,10 @@ export class InputPassword extends AKElement {
|
||||
|
||||
//#region Refs
|
||||
|
||||
inputRef: Ref<HTMLInputElement> = createRef();
|
||||
@property({ attribute: false, useDefault: true })
|
||||
public inputRef: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
|
||||
public toggleVisibilityRef = createRef<HTMLButtonElement>();
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
|
||||
@intersectionObserver()
|
||||
public visible = false;
|
||||
|
||||
protected autofocusTarget = new FocusTarget();
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
focus = this.autofocusTarget.focus;
|
||||
|
||||
#visibilityListener = () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AKLabel } from "#components/ak-label";
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
|
||||
import CaptchaDisplayController from "#flow/stages/identification/controllers/CaptchaDisplayController";
|
||||
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import RememberMeController from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
|
||||
import Styles from "#flow/stages/identification/styles.css";
|
||||
|
||||
@@ -30,6 +30,7 @@ import { match } from "ts-pattern";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
|
||||
import { createRef, ref } from "lit-html/directives/ref.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
@@ -45,8 +46,6 @@ type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" |
|
||||
|
||||
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
|
||||
|
||||
type EmptyString = string | null | undefined;
|
||||
|
||||
export const PasswordManagerPrefill: {
|
||||
password?: string;
|
||||
totp?: string;
|
||||
@@ -82,21 +81,26 @@ export class IdentificationStage extends BaseStage<
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
...RememberMe.styles,
|
||||
...RememberMeController.styles,
|
||||
Styles,
|
||||
];
|
||||
|
||||
/**
|
||||
* The ID of the input field.
|
||||
* The ID of the identifier input field, used for accessibility and focus management.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String, attribute: "input-id" })
|
||||
public inputID = "ak-identifier-input";
|
||||
|
||||
protected passwordFieldRef = createRef<HTMLInputElement>();
|
||||
|
||||
#form?: HTMLFormElement;
|
||||
|
||||
private rememberMe = new RememberMe(this);
|
||||
public defaultUserIdentification: string | null = null;
|
||||
|
||||
protected rememberMeController: RememberMeController | null = null;
|
||||
|
||||
#autoRedirect = new AutoRedirect(this);
|
||||
#captcha = new CaptchaDisplayController(this);
|
||||
#webauthn = new WebauthnController(this);
|
||||
@@ -109,15 +113,23 @@ export class IdentificationStage extends BaseStage<
|
||||
super();
|
||||
// We _define and instantiate_ these fields above, then _read_ them here, and that satisfies
|
||||
// the lint pass that there are no unused private fields.
|
||||
this.addController(this.rememberMe);
|
||||
this.addController(this.#autoRedirect);
|
||||
this.addController(this.#captcha);
|
||||
this.addController(this.#webauthn);
|
||||
}
|
||||
|
||||
#prepareRememberMeFrame = -1;
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("challenge") && this.challenge) {
|
||||
cancelAnimationFrame(this.#prepareRememberMeFrame);
|
||||
|
||||
this.#prepareRememberMeFrame = requestAnimationFrame(() => {
|
||||
this.prepareRememberMeController();
|
||||
});
|
||||
|
||||
this.#createHelperForm();
|
||||
}
|
||||
}
|
||||
@@ -127,10 +139,46 @@ export class IdentificationStage extends BaseStage<
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
cancelAnimationFrame(this.#prepareRememberMeFrame);
|
||||
}
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.focus();
|
||||
}
|
||||
|
||||
protected prepareRememberMeController(): void {
|
||||
if (!this.challenge) return;
|
||||
|
||||
const { enableRememberMe, pendingUserIdentifier = null } = this.challenge;
|
||||
|
||||
if (!enableRememberMe) {
|
||||
this.defaultUserIdentification = pendingUserIdentifier;
|
||||
|
||||
if (this.rememberMeController) {
|
||||
this.removeController(this.rememberMeController);
|
||||
this.rememberMeController = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.rememberMeController) {
|
||||
this.rememberMeController = new RememberMeController(this, {
|
||||
identificationFieldID: this.inputID,
|
||||
identificationFieldRef: this.autofocusTarget.reference,
|
||||
passwordFieldRef: this.passwordFieldRef,
|
||||
pendingUserIdentifier,
|
||||
});
|
||||
|
||||
this.addController(this.rememberMeController);
|
||||
}
|
||||
|
||||
this.defaultUserIdentification = this.rememberMeController.defaultUserIdentification;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Helper Form
|
||||
@@ -247,11 +295,11 @@ export class IdentificationStage extends BaseStage<
|
||||
id: string,
|
||||
type: string,
|
||||
label: string,
|
||||
username: EmptyString,
|
||||
initialUserIdentification: string | null,
|
||||
autocomplete: string,
|
||||
) {
|
||||
return html`<input
|
||||
${this.autofocusTarget.toRef()}
|
||||
${ref(this.autofocusTarget.reference)}
|
||||
id=${id}
|
||||
type=${type}
|
||||
name="uidField"
|
||||
@@ -260,56 +308,57 @@ export class IdentificationStage extends BaseStage<
|
||||
autocomplete=${autocomplete}
|
||||
spellcheck="false"
|
||||
class="pf-c-form-control"
|
||||
value=${username ?? ""}
|
||||
value=${initialUserIdentification ?? ""}
|
||||
required
|
||||
/>`;
|
||||
}
|
||||
|
||||
protected renderPasswordFields(challenge: IdentificationChallenge) {
|
||||
const { allowShowPassword } = challenge;
|
||||
return html`
|
||||
<ak-flow-input-password
|
||||
label=${msg("Password")}
|
||||
input-id="ak-stage-identification-password"
|
||||
class="pf-c-form__group"
|
||||
.errors=${challenge.responseErrors?.password}
|
||||
?allow-show-password=${allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill.password ?? ""}
|
||||
></ak-flow-input-password>
|
||||
`;
|
||||
return html`<ak-flow-input-password
|
||||
.inputRef=${this.passwordFieldRef}
|
||||
label=${msg("Password")}
|
||||
input-id="ak-stage-identification-password"
|
||||
class="pf-c-form__group"
|
||||
.errors=${challenge.responseErrors?.password}
|
||||
?allow-show-password=${allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill.password ?? ""}
|
||||
></ak-flow-input-password> `;
|
||||
}
|
||||
|
||||
protected renderInput(challenge: IdentificationChallenge) {
|
||||
const {
|
||||
flowDesignation,
|
||||
passwordFields,
|
||||
passwordlessUrl,
|
||||
pendingUserIdentifier,
|
||||
primaryAction,
|
||||
userFields,
|
||||
} = challenge;
|
||||
const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } =
|
||||
challenge;
|
||||
|
||||
const fields = (userFields || []).sort();
|
||||
if (fields.length === 0) {
|
||||
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
|
||||
}
|
||||
|
||||
const { inputID, rememberMe } = this;
|
||||
const {
|
||||
inputID,
|
||||
defaultUserIdentification: initialUserIdentification,
|
||||
rememberMeController,
|
||||
} = this;
|
||||
|
||||
const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery;
|
||||
const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text";
|
||||
const label = OR_LIST_FORMATTERS.format(fields.map((f) => UI_FIELDS[f]));
|
||||
const username = rememberMe.username ?? pendingUserIdentifier;
|
||||
|
||||
// When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill
|
||||
const autocomplete: AutoFill = this.#webauthn.live ? "username webauthn" : "username";
|
||||
|
||||
console.debug(
|
||||
"Rendering identification stage with fields:",
|
||||
fields,
|
||||
initialUserIdentification,
|
||||
);
|
||||
// prettier-ignore
|
||||
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: inputID }, label)}
|
||||
${this.renderUidField(inputID, type, label, username, autocomplete)}
|
||||
${rememberMe.render()}
|
||||
${this.renderUidField(inputID, type, label, initialUserIdentification, autocomplete)}
|
||||
${rememberMeController?.renderToggleInput() ?? null}
|
||||
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
|
||||
</div>
|
||||
${passwordFields ? this.renderPasswordFields(challenge) : nothing}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { StorageAccessor } from "#common/storage";
|
||||
import { getCookie } from "#common/utils";
|
||||
|
||||
import { ReactiveElementHost } from "#elements/types";
|
||||
|
||||
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, ReactiveController } from "lit";
|
||||
import { createRef, Ref } from "lit-html/directives/ref.js";
|
||||
|
||||
export class RememberMeStorage {
|
||||
static readonly user = StorageAccessor.local("authentik-remember-me-user");
|
||||
static readonly session = StorageAccessor.local("authentik-remember-me-session");
|
||||
static reset = () => {
|
||||
this.user.delete();
|
||||
this.session.delete();
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionID() {
|
||||
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
|
||||
}
|
||||
|
||||
export interface RememberMeControllerInit {
|
||||
pendingUserIdentifier: string | null;
|
||||
identificationFieldRef: Ref<HTMLInputElement>;
|
||||
passwordFieldRef: Ref<HTMLInputElement> | null;
|
||||
identificationFieldID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the user's `username` "on this device."
|
||||
@@ -24,7 +48,7 @@ type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
* came back to this view after reaching the identity proof phase, indicating they pressed the "not
|
||||
* you?" link, at which point it begins again to record the username as it is typed in.
|
||||
*/
|
||||
export class RememberMe implements ReactiveController {
|
||||
export class RememberMeController implements ReactiveController {
|
||||
static readonly styles = [
|
||||
css`
|
||||
.remember-me-switch {
|
||||
@@ -35,121 +59,178 @@ export class RememberMe implements ReactiveController {
|
||||
`,
|
||||
];
|
||||
|
||||
public username?: string;
|
||||
//#region Lifecycle
|
||||
|
||||
#trackRememberMe = () => {
|
||||
if (!this.#usernameField || this.#usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.#usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
};
|
||||
public readonly identificationFieldRef: Ref<HTMLInputElement>;
|
||||
public readonly passwordFieldRef: Ref<HTMLInputElement> | null;
|
||||
public readonly defaultChecked: boolean;
|
||||
public readonly defaultUserIdentification: string | null;
|
||||
public readonly identificationFieldID: string;
|
||||
|
||||
// When active, save current details and record every keystroke to the username.
|
||||
// When inactive, clear all fields and remove keystroke recorder.
|
||||
#toggleRememberMe = () => {
|
||||
if (!this.#rememberMeToggle || !this.#rememberMeToggle.checked) {
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
localStorage?.removeItem("authentik-remember-me-session");
|
||||
this.username = undefined;
|
||||
this.#usernameField?.removeEventListener("keyup", this.#trackRememberMe);
|
||||
return;
|
||||
}
|
||||
if (!this.#usernameField) {
|
||||
return;
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-user", this.#usernameField.value);
|
||||
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
|
||||
this.#usernameField.addEventListener("keyup", this.#trackRememberMe);
|
||||
};
|
||||
protected logger = ConsoleLogger.prefix("controller/remember-me");
|
||||
protected autoSubmitAttempts = 0;
|
||||
protected currentSessionID = readSessionID();
|
||||
|
||||
constructor(private host: RememberMeHost) {}
|
||||
constructor(
|
||||
protected host: ReactiveElementHost<IdentificationStage>,
|
||||
{
|
||||
identificationFieldRef,
|
||||
passwordFieldRef,
|
||||
identificationFieldID,
|
||||
}: RememberMeControllerInit,
|
||||
) {
|
||||
this.identificationFieldRef = identificationFieldRef;
|
||||
this.passwordFieldRef = passwordFieldRef || null;
|
||||
this.identificationFieldID = identificationFieldID;
|
||||
|
||||
// Record a stable token that we can use between requests to track if we've
|
||||
// been here before. If we can't, clear out the username.
|
||||
public hostConnected() {
|
||||
try {
|
||||
const sessionId = localStorage.getItem("authentik-remember-me-session");
|
||||
if (!!this.#localSession && sessionId === this.#localSession) {
|
||||
this.username = undefined;
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
}
|
||||
const persistedSessionID = RememberMeStorage.session.read();
|
||||
|
||||
get #localSession() {
|
||||
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
|
||||
}
|
||||
|
||||
get #usernameField() {
|
||||
return this.host.renderRoot.querySelector(
|
||||
'input[name="uidField"]',
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
get #rememberMeToggle() {
|
||||
return this.host.renderRoot.querySelector(
|
||||
"#authentik-remember-me",
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
get #submitButton() {
|
||||
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
get #isEnabled() {
|
||||
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
|
||||
}
|
||||
|
||||
get #canAutoSubmit() {
|
||||
return (
|
||||
!!this.host.challenge &&
|
||||
!!this.username &&
|
||||
!!this.#usernameField?.value &&
|
||||
!this.host.challenge.passwordFields &&
|
||||
!this.host.challenge.passwordlessUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Before the page is updated, try to extract the username from localstorage.
|
||||
public hostUpdate() {
|
||||
if (!this.#isEnabled) {
|
||||
return;
|
||||
if (persistedSessionID && persistedSessionID !== this.currentSessionID) {
|
||||
this.logger.debug("Session ID mismatch, clearing remembered username");
|
||||
RememberMeStorage.user.delete();
|
||||
}
|
||||
|
||||
try {
|
||||
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
const persistedUserIdentifier = RememberMeStorage.user.read();
|
||||
|
||||
this.defaultUserIdentification =
|
||||
persistedUserIdentifier || this.host.challenge?.pendingUserIdentifier || null;
|
||||
|
||||
this.defaultChecked = !!persistedUserIdentifier;
|
||||
}
|
||||
|
||||
// After the page is updated, if everything is ready to go, do the autosubmit.
|
||||
public hostUpdated() {
|
||||
if (this.#isEnabled && this.#canAutoSubmit) {
|
||||
this.#submitButton?.click();
|
||||
if (this.canAutoSubmit() && this.autoSubmitAttempts === 0) {
|
||||
this.autoSubmitAttempts++;
|
||||
this.host.submitForm?.();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.#isEnabled
|
||||
? html` <label class="pf-c-switch remember-me-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
id="authentik-remember-me"
|
||||
@click=${this.#toggleRememberMe}
|
||||
type="checkbox"
|
||||
?checked=${!!this.username}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`
|
||||
: nothing;
|
||||
//#region Event Listeners
|
||||
|
||||
#writeFrameID = -1;
|
||||
|
||||
public inputListener = (event: InputEvent) => {
|
||||
cancelAnimationFrame(this.#writeFrameID);
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
|
||||
this.#writeFrameID = requestAnimationFrame(() => {
|
||||
RememberMeStorage.user.write(value);
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public API
|
||||
|
||||
/**
|
||||
* Toggle the "remember me" feature on or off.
|
||||
*
|
||||
* When toggled on, the current username is saved to localStorage and will be automatically
|
||||
* submitted on future visits. Additionally, every keystroke in the username field will update
|
||||
* the stored username.
|
||||
*
|
||||
* When toggled off, any stored username is cleared from localStorage, and the keystroke listener
|
||||
* is removed to stop updating the stored username.
|
||||
*/
|
||||
public toggleChangeListener = (event: Event) => {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
const { usernameField, passwordField } = this;
|
||||
|
||||
if (!checkbox.checked) {
|
||||
this.logger.debug("Disabling remember me");
|
||||
|
||||
RememberMeStorage.reset();
|
||||
|
||||
if (usernameField) {
|
||||
usernameField.removeEventListener("input", this.inputListener);
|
||||
usernameField.focus();
|
||||
usernameField.select();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usernameField) {
|
||||
this.logger.warn("Cannot enable remember me: no username field found");
|
||||
return;
|
||||
}
|
||||
|
||||
const focusTarget = passwordField && usernameField?.value ? passwordField : usernameField;
|
||||
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
focusTarget.select();
|
||||
}
|
||||
|
||||
this.logger.debug("Enabling remember me for user");
|
||||
|
||||
RememberMeStorage.user.write(usernameField.value);
|
||||
RememberMeStorage.session.write(this.currentSessionID);
|
||||
|
||||
usernameField.addEventListener("input", this.inputListener, {
|
||||
passive: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the "remember me" feature can be automatically submitted, which requires:
|
||||
*
|
||||
* - An active challenge.
|
||||
* - A stored username from a previous session.
|
||||
* - The identifier input field to be present in the DOM.
|
||||
* - No password fields or passwordless URL, indicating we can skip directly to the next step.
|
||||
*/
|
||||
public canAutoSubmit(): boolean {
|
||||
const { challenge } = this.host;
|
||||
|
||||
if (!challenge) return false;
|
||||
if (!challenge.enableRememberMe) return false;
|
||||
|
||||
if (challenge.passwordFields) return false;
|
||||
if (challenge.passwordlessUrl) return false;
|
||||
|
||||
if (!this.defaultChecked) return false;
|
||||
return !!this.usernameField?.value;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
protected readonly checkboxRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected get usernameField() {
|
||||
return this.identificationFieldRef.value || null;
|
||||
}
|
||||
|
||||
protected get passwordField() {
|
||||
return this.passwordFieldRef?.value || null;
|
||||
}
|
||||
|
||||
protected get checkboxToggle() {
|
||||
return this.checkboxRef.value || null;
|
||||
}
|
||||
public renderToggleInput = () => {
|
||||
return html`<label
|
||||
class="pf-c-switch remember-me-switch"
|
||||
for="authentik-remember-me"
|
||||
aria-description=${msg(
|
||||
"When enabled, your username will be remembered on this device for future logins.",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
id="authentik-remember-me"
|
||||
@change=${this.toggleChangeListener}
|
||||
?checked=${this.defaultChecked}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`;
|
||||
};
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export default RememberMe;
|
||||
export default RememberMeController;
|
||||
|
||||
@@ -179,7 +179,7 @@ test.describe("Groups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
|
||||
test("Edit group from view page", async ({ form, pointer, page }, testInfo) => {
|
||||
const groupName = groupNames.get(testInfo.testId)!;
|
||||
|
||||
const { fill, search } = form;
|
||||
|
||||
@@ -17,11 +17,7 @@ test.describe("Provider Wizard", () => {
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({
|
||||
to: "/if/admin/#/core/providers",
|
||||
});
|
||||
});
|
||||
await test.step("Authenticate", async () => session.login());
|
||||
|
||||
await test.step("Navigate to provider wizard", async () => {
|
||||
await expect(dialog, "Dialog is initially closed").toBeHidden();
|
||||
|
||||
119
web/test/browser/session-lifecycle.test.ts
Normal file
119
web/test/browser/session-lifecycle.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { FormFixture } from "#e2e/fixtures/FormFixture";
|
||||
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
|
||||
import { GOOD_USERNAME, SessionFixture } from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
|
||||
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
|
||||
|
||||
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
|
||||
|
||||
const readStoredUserIdentifier = (page: Page) =>
|
||||
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
|
||||
|
||||
test.describe("Session Lifecycle", () => {
|
||||
test.beforeAll(
|
||||
'Ensure "Enable Remember me on this device" is on for the default identification stage',
|
||||
async ({ browser }, { title: testName }) => {
|
||||
if (Date.now()) return;
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const navigator = new NavigatorFixture(page, testName);
|
||||
const form = new FormFixture(page, testName);
|
||||
const session = new SessionFixture({ page, testName, navigator });
|
||||
|
||||
await test.step("Authenticate", async () =>
|
||||
session.login({
|
||||
to: "/if/admin/#/flow/stages",
|
||||
page,
|
||||
}));
|
||||
|
||||
const $stage = await test.step("Find stage via search", () =>
|
||||
form.search(IDENTIFICATION_STAGE_NAME, page));
|
||||
|
||||
await $stage.getByRole("button", { name: "Edit Stage" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Edit Identification Stage" });
|
||||
await expect(dialog, "Edit modal opens after clicking edit").toBeVisible();
|
||||
|
||||
await form.setInputCheck(`Enable "Remember me on this device"`, true, dialog);
|
||||
await dialog.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect(dialog, "Edit modal closes after save").toBeHidden();
|
||||
|
||||
await context.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ session, page }) => {
|
||||
await session.toLoginPage();
|
||||
|
||||
await page.evaluate(
|
||||
([userKey, sessionKey]) => {
|
||||
localStorage.removeItem(userKey);
|
||||
localStorage.removeItem(sessionKey);
|
||||
},
|
||||
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await session.$identificationStage.waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
test("Remember me persists username", async ({ navigator, session, page }) => {
|
||||
await test.step("Verify identification stage", async () => {
|
||||
await expect(
|
||||
session.$rememberMeCheckbox,
|
||||
"Remember me checkbox is visible",
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
session.$rememberMeCheckbox,
|
||||
"Remember me checkbox is not checked by default",
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
await test.step("Identify with remember-me enabled", async () => {
|
||||
await session.login(
|
||||
{
|
||||
rememberMe: true,
|
||||
to: "if/user/#/library",
|
||||
},
|
||||
page,
|
||||
);
|
||||
|
||||
const storedUserIdentifier = await readStoredUserIdentifier(page);
|
||||
|
||||
expect(
|
||||
storedUserIdentifier,
|
||||
"username persists to localStorage when remember-me is checked",
|
||||
).toBe(GOOD_USERNAME);
|
||||
});
|
||||
|
||||
await test.step("Sign out and verify username is remembered", async () => {
|
||||
const signOutLink = page.getByRole("link", { name: "Sign out" });
|
||||
|
||||
await expect(signOutLink, "Sign out link is visible").toBeVisible();
|
||||
|
||||
await signOutLink.click();
|
||||
|
||||
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
|
||||
|
||||
const notYouLink = page.getByRole("link", { name: "Not you?" });
|
||||
|
||||
await expect(notYouLink, "Not you? link is visible after sign out").toBeVisible();
|
||||
|
||||
await notYouLink.click();
|
||||
|
||||
await expect(
|
||||
session.$identificationStage,
|
||||
"Identification stage is visible after clicking not you link",
|
||||
).toBeVisible();
|
||||
|
||||
const storedUserIdentifier = await readStoredUserIdentifier(page);
|
||||
|
||||
expect(storedUserIdentifier, "Removed after clicking not you link").toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user