Compare commits

..

6 Commits

Author SHA1 Message Date
Marcelo Elizeche Landó
119c9d018a lifecycle: use subprocess.run for ak script wrapper 2026-05-04 19:29:13 -03:00
Teffen Ellis
d6c0ae21de web: Clear remember me before navigation. (#21647)
* web: Clear remember me before navigation.

* web: fix stray > in "Not you?" link and add Playwright regression for #21571

Move the closing > of the opening <a> tag so the rendered link text no longer
carries a leading > glyph. Add a browser test that seeds the identification
stage with enable_remember_me, walks the identify -> password -> "Not you?"
path, and asserts the link text, the cleared username field, and the cleared
remember-me localStorage key.
Co-Authored-By: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>

* Flesh out remember me lifecycle. Fix edgecases where it doesn't keep up with the e2e suite.

* Fix for submit events, labels.

---------

Co-authored-by: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>
2026-04-29 23:54:42 +02:00
dependabot[bot]
2c35df35b6 web: bump knip from 6.4.1 to 6.6.0 in /web (#21957)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.4.1 to 6.6.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.6.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:37:12 +02:00
dependabot[bot]
90d4f4296b core: bump github.com/getsentry/sentry-go from 0.45.1 to 0.46.0 (#21955)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.45.1 to 0.46.0.
- [Release notes](https://github.com/getsentry/sentry-go/releases)
- [Changelog](https://github.com/getsentry/sentry-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-go/compare/v0.45.1...v0.46.0)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:36:43 +02:00
dependabot[bot]
bf7747268b core: bump uvicorn[standard] from 0.44.0 to 0.45.0 (#21956)
Bumps [uvicorn[standard]](https://github.com/Kludex/uvicorn) from 0.44.0 to 0.45.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.44.0...0.45.0)

---
updated-dependencies:
- dependency-name: uvicorn[standard]
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:35:09 +02:00
dependabot[bot]
552cb78458 core: bump rustls from 0.23.39 to 0.23.40 (#21958)
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.39 to 0.23.40.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.39...v/0.23.40)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:34:27 +02:00
57 changed files with 883 additions and 1603 deletions

4
Cargo.lock generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""Common SAML Exceptions"""
"""authentik SAML IDP Exceptions"""
from authentik.lib.sentry import SentryIgnoredException

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 (

View File

@@ -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),
)

View File

@@ -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 (

View File

@@ -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"]

View File

@@ -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"),
],
},
),
]

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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),
]

View File

@@ -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):

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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__":

View File

@@ -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"];
}

View File

@@ -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"],
};
}

View File

@@ -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"],
};
}

View File

@@ -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"],
};
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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.*",

View File

@@ -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
View File

@@ -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]

View File

@@ -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,

View File

@@ -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
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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"

View File

@@ -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
View 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;
}
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}
/**

View File

@@ -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}

View File

@@ -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

View File

@@ -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 = () => {

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View 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();
});
});
});