mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
enterprise/providers/ws_fed: add SAML 1.1 support (#23851)
* enterprise/providers/ws_fed: add SAML 1.1 support thanks microsoft Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix session_index Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix issuer Signed-off-by: Jens Langhammer <jens@goauthentik.io> * show issuer url in ui too Signed-off-by: Jens Langhammer <jens@goauthentik.io> * app slug in URL to lookup realm when not in parameters Signed-off-by: Jens Langhammer <jens@goauthentik.io> * broader cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * sigh Signed-off-by: Jens Langhammer <jens@goauthentik.io> * sigh nr 2 Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@@ -4,6 +4,7 @@ from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from rest_framework.fields import CharField, SerializerMethodField, URLField
|
||||
|
||||
from authentik.common.saml.constants import DEFAULT_ISSUER
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
@@ -18,6 +19,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
|
||||
reply_url = URLField(source="acs_url")
|
||||
wtrealm = CharField(source="audience")
|
||||
url_wsfed = SerializerMethodField()
|
||||
url_issuer = SerializerMethodField()
|
||||
|
||||
def get_url_download_metadata(self, instance: WSFederationProvider) -> str:
|
||||
"""Get metadata download URL"""
|
||||
@@ -47,7 +49,32 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
|
||||
if "request" not in self._context:
|
||||
return ""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
return request.build_absolute_uri(reverse("authentik_providers_ws_federation:wsfed"))
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_ws_federation:wsfed-app-specific",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return ""
|
||||
|
||||
def get_url_issuer(self, instance: WSFederationProvider) -> str:
|
||||
"""Get Issuer/EntityID URL"""
|
||||
if instance.issuer_override:
|
||||
return instance.issuer_override
|
||||
if "request" not in self._context:
|
||||
return DEFAULT_ISSUER
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_ws_federation:metadata-download",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return DEFAULT_ISSUER
|
||||
|
||||
class Meta(SAMLProviderSerializer.Meta):
|
||||
model = WSFederationProvider
|
||||
@@ -60,6 +87,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
|
||||
"property_mappings",
|
||||
"name_id_mapping",
|
||||
"authn_context_class_ref_mapping",
|
||||
"saml_version",
|
||||
"digest_algorithm",
|
||||
"signature_algorithm",
|
||||
"signing_kp",
|
||||
@@ -69,6 +97,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
|
||||
"default_name_id_policy",
|
||||
"url_download_metadata",
|
||||
"url_wsfed",
|
||||
"url_issuer",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-08 12:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_ws_federation", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="wsfederationprovider",
|
||||
name="saml_version",
|
||||
field=models.TextField(
|
||||
choices=[("1.1", "SAML 1.1"), ("2.0", "SAML 2.0")],
|
||||
default="2.0",
|
||||
help_text="SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db import models
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
@@ -5,6 +6,13 @@ from rest_framework.serializers import Serializer
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
|
||||
|
||||
class WSFederationSAMLVersion(models.TextChoices):
|
||||
"""SAML Assertion version issued by a WS-Federation provider"""
|
||||
|
||||
SAML_1_1 = "1.1", _("SAML 1.1")
|
||||
SAML_2_0 = "2.0", _("SAML 2.0")
|
||||
|
||||
|
||||
class WSFederationProvider(SAMLProvider):
|
||||
"""WS-Federation for applications which support WS-Fed."""
|
||||
|
||||
@@ -12,6 +20,15 @@ class WSFederationProvider(SAMLProvider):
|
||||
# - acs_url -> reply_url
|
||||
# - audience -> realm / wtrealm
|
||||
|
||||
saml_version = models.TextField(
|
||||
choices=WSFederationSAMLVersion.choices,
|
||||
default=WSFederationSAMLVersion.SAML_2_0,
|
||||
help_text=_(
|
||||
"SAML assertion version to issue in the security token. Microsoft Entra ID and "
|
||||
"classic ADFS-style relying parties typically require SAML 1.1."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.enterprise.providers.ws_federation.api.providers import (
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""SAML 1.1 Assertion generator for WS-Federation"""
|
||||
|
||||
from hashlib import sha256
|
||||
from types import GeneratorType
|
||||
|
||||
import xmlsec
|
||||
from lxml.etree import Element, SubElement, _Element # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_SIGNATURE,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import get_login_event
|
||||
from authentik.lib.xml import remove_xml_newlines
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
from authentik.sources.saml.exceptions import InvalidSignature
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# SAML 1.0 and 1.1 share this namespace; version is set via MajorVersion/MinorVersion attributes
|
||||
NS_SAML11_ASSERTION = "urn:oasis:names:tc:SAML:1.0:assertion"
|
||||
NS_MAP_SAML11 = {"saml": NS_SAML11_ASSERTION, "ds": NS_SIGNATURE}
|
||||
|
||||
SAML11_CM_BEARER = "urn:oasis:names:tc:SAML:1.0:cm:bearer"
|
||||
SAML11_AM_PASSWORD = "urn:oasis:names:tc:SAML:1.0:am:password"
|
||||
SAML11_AM_UNSPECIFIED = "urn:oasis:names:tc:SAML:1.0:am:unspecified"
|
||||
WSFED_ATTRIBUTE_NAMESPACE = "http://schemas.xmlsoap.org/claims"
|
||||
|
||||
|
||||
class SAML11AssertionProcessor(AssertionProcessor):
|
||||
"""SAML 1.1 assertion builder, overriding the SAML 2.0 methods that differ."""
|
||||
|
||||
def get_name_id(self) -> _Element:
|
||||
# same value/format resolution as SAML 2.0, wrapped as NameIdentifier instead of NameID
|
||||
name_id = super().get_name_id()
|
||||
name_identifier = Element(f"{{{NS_SAML11_ASSERTION}}}NameIdentifier")
|
||||
name_identifier.attrib["Format"] = name_id.attrib["Format"]
|
||||
name_identifier.text = name_id.text
|
||||
return name_identifier
|
||||
|
||||
def get_assertion_subject(self) -> _Element:
|
||||
# SAML 1.1 has no assertion-level Subject; each statement embeds its own
|
||||
subject = Element(f"{{{NS_SAML11_ASSERTION}}}Subject")
|
||||
subject.append(self.get_name_id())
|
||||
|
||||
subject_confirmation = SubElement(subject, f"{{{NS_SAML11_ASSERTION}}}SubjectConfirmation")
|
||||
confirmation_method = SubElement(
|
||||
subject_confirmation, f"{{{NS_SAML11_ASSERTION}}}ConfirmationMethod"
|
||||
)
|
||||
confirmation_method.text = SAML11_CM_BEARER
|
||||
return subject
|
||||
|
||||
def get_assertion_conditions(self) -> _Element:
|
||||
conditions = Element(f"{{{NS_SAML11_ASSERTION}}}Conditions")
|
||||
conditions.attrib["NotBefore"] = self._valid_not_before
|
||||
conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after
|
||||
if self.provider.audience != "":
|
||||
audience_restriction_condition = SubElement(
|
||||
conditions, f"{{{NS_SAML11_ASSERTION}}}AudienceRestrictionCondition"
|
||||
)
|
||||
audience = SubElement(
|
||||
audience_restriction_condition, f"{{{NS_SAML11_ASSERTION}}}Audience"
|
||||
)
|
||||
audience.text = self.provider.audience
|
||||
return conditions
|
||||
|
||||
def get_assertion_auth_n_statement(self) -> _Element:
|
||||
auth_statement = Element(f"{{{NS_SAML11_ASSERTION}}}AuthenticationStatement")
|
||||
auth_statement.attrib["AuthenticationInstant"] = self._auth_instant
|
||||
auth_statement.attrib["AuthenticationMethod"] = SAML11_AM_UNSPECIFIED
|
||||
self.session_index = sha256(
|
||||
self.http_request.session.session_key.encode("ascii")
|
||||
).hexdigest()
|
||||
event = get_login_event(self.http_request)
|
||||
if event and event.context.get(PLAN_CONTEXT_METHOD, "") == "password":
|
||||
auth_statement.attrib["AuthenticationMethod"] = SAML11_AM_PASSWORD
|
||||
|
||||
if self.provider.authn_context_class_ref_mapping:
|
||||
try:
|
||||
value = self.provider.authn_context_class_ref_mapping.evaluate(
|
||||
user=self.http_request.user,
|
||||
request=self.http_request,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is not None:
|
||||
auth_statement.attrib["AuthenticationMethod"] = str(value)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=(
|
||||
"Failed to evaluate property-mapping: "
|
||||
f"'{self.provider.authn_context_class_ref_mapping.name}'"
|
||||
),
|
||||
provider=self.provider,
|
||||
mapping=self.provider.authn_context_class_ref_mapping,
|
||||
).from_http(self.http_request)
|
||||
LOGGER.warning("Failed to evaluate property mapping", exc=exc)
|
||||
|
||||
auth_statement.append(self.get_assertion_subject())
|
||||
return auth_statement
|
||||
|
||||
def get_attributes(self) -> _Element | None:
|
||||
# None if empty: SAML 1.1's schema requires at least one Attribute per AttributeStatement
|
||||
attribute_statement = Element(f"{{{NS_SAML11_ASSERTION}}}AttributeStatement")
|
||||
attribute_statement.append(self.get_assertion_subject())
|
||||
user = self.http_request.user
|
||||
has_attribute = False
|
||||
for mapping in SAMLPropertyMapping.objects.filter(provider=self.provider).order_by(
|
||||
"saml_name"
|
||||
):
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=user,
|
||||
request=self.http_request,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
attribute = Element(f"{{{NS_SAML11_ASSERTION}}}Attribute")
|
||||
attribute.attrib["AttributeName"] = mapping.saml_name
|
||||
attribute.attrib["AttributeNamespace"] = WSFED_ATTRIBUTE_NAMESPACE
|
||||
|
||||
if not isinstance(value, list | GeneratorType):
|
||||
value = [value]
|
||||
|
||||
for value_item in value:
|
||||
attribute_value = SubElement(
|
||||
attribute, f"{{{NS_SAML11_ASSERTION}}}AttributeValue"
|
||||
)
|
||||
str_value = str(value_item) if not isinstance(value_item, str) else value_item
|
||||
attribute_value.text = str_value
|
||||
|
||||
attribute_statement.append(attribute)
|
||||
has_attribute = True
|
||||
except (PropertyMappingExpressionException, ValueError) as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping: '{mapping.name}'",
|
||||
provider=self.provider,
|
||||
mapping=mapping,
|
||||
).from_http(self.http_request)
|
||||
LOGGER.warning("Failed to evaluate property mapping", exc=exc)
|
||||
continue
|
||||
if not has_attribute:
|
||||
return None
|
||||
return attribute_statement
|
||||
|
||||
def get_assertion(self) -> _Element:
|
||||
assertion = Element(f"{{{NS_SAML11_ASSERTION}}}Assertion", nsmap=NS_MAP_SAML11)
|
||||
assertion.attrib["MajorVersion"] = "1"
|
||||
assertion.attrib["MinorVersion"] = "1"
|
||||
assertion.attrib["AssertionID"] = self._assertion_id
|
||||
assertion.attrib["IssueInstant"] = self._issue_instant
|
||||
self.issuer = self._get_issuer_value()
|
||||
assertion.attrib["Issuer"] = self.issuer
|
||||
|
||||
assertion.append(self.get_assertion_conditions())
|
||||
assertion.append(self.get_assertion_auth_n_statement())
|
||||
attribute_statement = self.get_attributes()
|
||||
if attribute_statement is not None:
|
||||
assertion.append(attribute_statement)
|
||||
|
||||
# unlike SAML 2.0, ds:Signature must be the last child of Assertion
|
||||
if self.provider.signing_kp and self.provider.sign_assertion:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
assertion,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
sign_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
assertion.append(signature)
|
||||
|
||||
return assertion
|
||||
|
||||
def _sign(self, element: _Element):
|
||||
# same as AssertionProcessor._sign, but referencing AssertionID instead of ID
|
||||
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
|
||||
self.provider.digest_algorithm, xmlsec.constants.TransformSha1
|
||||
)
|
||||
xmlsec.tree.add_ids(element, ["AssertionID"])
|
||||
signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + element.attrib["AssertionID"],
|
||||
)
|
||||
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.provider.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
key.load_cert_from_memory(
|
||||
self.provider.signing_kp.certificate_data,
|
||||
xmlsec.constants.KeyDataFormatCertPem,
|
||||
)
|
||||
ctx.key = key
|
||||
try:
|
||||
ctx.sign(remove_xml_newlines(element, signature_node))
|
||||
except xmlsec.Error as exc:
|
||||
raise InvalidSignature() from exc
|
||||
@@ -1,9 +1,13 @@
|
||||
from authentik.common.saml.constants import NS_MAP as _map
|
||||
from authentik.enterprise.providers.ws_federation.models import WSFederationSAMLVersion
|
||||
|
||||
WS_FED_ACTION_SIGN_IN = "wsignin1.0"
|
||||
WS_FED_ACTION_SIGN_OUT = "wsignout1.0"
|
||||
WS_FED_ACTION_SIGN_OUT_CLEANUP = "wsignoutcleanup1.0"
|
||||
|
||||
WS_FED_QS_ACTION = "wa"
|
||||
WS_FED_QS_REALM = "wtrealm"
|
||||
WS_FED_QS_REPLY = "wreply"
|
||||
WS_FED_POST_KEY_ACTION = "wa"
|
||||
WS_FED_POST_KEY_RESULT = "wresult"
|
||||
WS_FED_POST_KEY_CONTEXT = "wctx"
|
||||
@@ -11,10 +15,18 @@ WS_FED_POST_KEY_CONTEXT = "wctx"
|
||||
WSS_TOKEN_TYPE_SAML2 = (
|
||||
"http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0" # nosec
|
||||
)
|
||||
WSS_TOKEN_TYPE_SAML11 = (
|
||||
"http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV1.1" # nosec
|
||||
)
|
||||
WSS_KEY_IDENTIFIER_SAML_ID = (
|
||||
"http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLID"
|
||||
)
|
||||
|
||||
WSS_TOKEN_TYPE_BY_VERSION = {
|
||||
WSFederationSAMLVersion.SAML_1_1: WSS_TOKEN_TYPE_SAML11,
|
||||
WSFederationSAMLVersion.SAML_2_0: WSS_TOKEN_TYPE_SAML2,
|
||||
}
|
||||
|
||||
NS_WS_FED_PROTOCOL = "http://docs.oasis-open.org/wsfed/federation/200706"
|
||||
NS_WS_FED_TRUST = "http://schemas.xmlsoap.org/ws/2005/02/trust"
|
||||
NS_WSI = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
||||
@@ -22,7 +22,12 @@ class MetadataProcessor(BaseMetadataProcessor):
|
||||
|
||||
address = SubElement(endpoint_ref, f"{{{NS_ADDRESSING}}}Address", nsmap=NS_MAP)
|
||||
address.text = self.http_request.build_absolute_uri(
|
||||
reverse("authentik_providers_ws_federation:wsfed")
|
||||
reverse(
|
||||
"authentik_providers_ws_federation:wsfed-app-specific",
|
||||
kwargs={
|
||||
"application_slug": self.provider.application.slug,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def add_role_descriptor_sts(self, entity_descriptor: _Element):
|
||||
|
||||
@@ -7,7 +7,13 @@ from lxml import etree # nosec
|
||||
from lxml.etree import Element, SubElement, _Element # nosec
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
|
||||
from authentik.enterprise.providers.ws_federation.models import (
|
||||
WSFederationProvider,
|
||||
WSFederationSAMLVersion,
|
||||
)
|
||||
from authentik.enterprise.providers.ws_federation.processors.assertion_saml11 import (
|
||||
SAML11AssertionProcessor,
|
||||
)
|
||||
from authentik.enterprise.providers.ws_federation.processors.constants import (
|
||||
NS_ADDRESSING,
|
||||
NS_MAP,
|
||||
@@ -20,8 +26,11 @@ from authentik.enterprise.providers.ws_federation.processors.constants import (
|
||||
WS_FED_POST_KEY_ACTION,
|
||||
WS_FED_POST_KEY_CONTEXT,
|
||||
WS_FED_POST_KEY_RESULT,
|
||||
WS_FED_QS_ACTION,
|
||||
WS_FED_QS_REALM,
|
||||
WS_FED_QS_REPLY,
|
||||
WSS_KEY_IDENTIFIER_SAML_ID,
|
||||
WSS_TOKEN_TYPE_SAML2,
|
||||
WSS_TOKEN_TYPE_BY_VERSION,
|
||||
)
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.utils import delete_none_values
|
||||
@@ -39,28 +48,26 @@ class SignInRequest:
|
||||
|
||||
@staticmethod
|
||||
def parse(request: HttpRequest) -> SignInRequest:
|
||||
action = request.GET.get("wa")
|
||||
if action != WS_FED_ACTION_SIGN_IN:
|
||||
raise ValueError("Invalid action")
|
||||
realm = request.GET.get("wtrealm")
|
||||
if not realm:
|
||||
raise ValueError("Missing Realm")
|
||||
|
||||
req = SignInRequest(
|
||||
wa=action,
|
||||
wtrealm=realm,
|
||||
wreply=request.GET.get("wreply"),
|
||||
wctx=request.GET.get("wctx", ""),
|
||||
wa=request.GET.get(WS_FED_QS_ACTION),
|
||||
wtrealm=request.GET.get(WS_FED_QS_REALM),
|
||||
wreply=request.GET.get(WS_FED_QS_REPLY),
|
||||
wctx=request.GET.get(WS_FED_POST_KEY_CONTEXT, ""),
|
||||
)
|
||||
return req
|
||||
|
||||
_, provider = req.get_app_provider()
|
||||
if not req.wreply:
|
||||
req.wreply = provider.acs_url
|
||||
reply = urlparse(req.wreply)
|
||||
def __post_init__(self):
|
||||
if self.wa != WS_FED_ACTION_SIGN_IN:
|
||||
raise ValueError("Invalid action")
|
||||
if not self.wtrealm:
|
||||
raise ValueError("Missing Realm")
|
||||
_, provider = self.get_app_provider()
|
||||
if not self.wreply:
|
||||
self.wreply = provider.acs_url
|
||||
reply = urlparse(self.wreply)
|
||||
configured = urlparse(provider.acs_url)
|
||||
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
|
||||
raise ValueError("Invalid wreply")
|
||||
return req
|
||||
|
||||
def get_app_provider(self):
|
||||
provider: WSFederationProvider = get_object_or_404(
|
||||
@@ -82,7 +89,12 @@ class SignInProcessor:
|
||||
self.provider = provider
|
||||
self.request = request
|
||||
self.sign_in_request = sign_in_request
|
||||
self.saml_processor = AssertionProcessor(self.provider, self.request, AuthNRequest())
|
||||
processor_cls = (
|
||||
SAML11AssertionProcessor
|
||||
if self.provider.saml_version == WSFederationSAMLVersion.SAML_1_1
|
||||
else AssertionProcessor
|
||||
)
|
||||
self.saml_processor = processor_cls(self.provider, self.request, AuthNRequest())
|
||||
self.saml_processor.provider.audience = self.sign_in_request.wtrealm
|
||||
if self.provider.signing_kp:
|
||||
self.saml_processor.provider.sign_assertion = True
|
||||
@@ -105,7 +117,7 @@ class SignInProcessor:
|
||||
)
|
||||
|
||||
token_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}TokenType")
|
||||
token_type.text = WSS_TOKEN_TYPE_SAML2
|
||||
token_type.text = WSS_TOKEN_TYPE_BY_VERSION[self.provider.saml_version]
|
||||
|
||||
request_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}RequestType")
|
||||
request_type.text = "http://schemas.xmlsoap.org/ws/2005/02/trust/Issue"
|
||||
@@ -143,7 +155,9 @@ class SignInProcessor:
|
||||
def response_add_attached_reference(self, tag: str, value: str) -> _Element:
|
||||
ref = Element(f"{{{NS_WS_FED_TRUST}}}{tag}")
|
||||
sec_token_ref = SubElement(ref, f"{{{NS_WSS_SEC}}}SecurityTokenReference")
|
||||
sec_token_ref.attrib[f"{{{NS_WSS_D3P1}}}TokenType"] = WSS_TOKEN_TYPE_SAML2
|
||||
sec_token_ref.attrib[f"{{{NS_WSS_D3P1}}}TokenType"] = WSS_TOKEN_TYPE_BY_VERSION[
|
||||
self.provider.saml_version
|
||||
]
|
||||
|
||||
key_identifier = SubElement(sec_token_ref, f"{{{NS_WSS_SEC}}}KeyIdentifier")
|
||||
key_identifier.attrib["ValueType"] = WSS_KEY_IDENTIFIER_SAML_ID
|
||||
@@ -152,7 +166,8 @@ class SignInProcessor:
|
||||
|
||||
def response(self) -> dict[str, str]:
|
||||
root = self.create_response_token()
|
||||
assertion = root.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
|
||||
# match by local name, since "saml" may be bound to the 1.1 or 2.0 namespace here
|
||||
assertion = root.xpath("//*[local-name()='Assertion']")[0]
|
||||
if self.provider.signing_kp:
|
||||
self.saml_processor._sign(assertion)
|
||||
str_token = etree.tostring(root).decode("utf-8") # nosec
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import InitVar, dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.http import HttpRequest
|
||||
@@ -6,7 +6,12 @@ from django.shortcuts import get_object_or_404
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
|
||||
from authentik.enterprise.providers.ws_federation.processors.constants import WS_FED_ACTION_SIGN_OUT
|
||||
from authentik.enterprise.providers.ws_federation.processors.constants import (
|
||||
WS_FED_ACTION_SIGN_OUT,
|
||||
WS_FED_QS_ACTION,
|
||||
WS_FED_QS_REALM,
|
||||
WS_FED_QS_REPLY,
|
||||
)
|
||||
|
||||
|
||||
@dataclass()
|
||||
@@ -14,30 +19,38 @@ class SignOutRequest:
|
||||
wa: str
|
||||
wtrealm: str
|
||||
wreply: str
|
||||
request: InitVar[HttpRequest]
|
||||
|
||||
@staticmethod
|
||||
def parse(request: HttpRequest) -> SignOutRequest:
|
||||
action = request.GET.get("wa")
|
||||
if action != WS_FED_ACTION_SIGN_OUT:
|
||||
raise ValueError("Invalid action")
|
||||
realm = request.GET.get("wtrealm")
|
||||
if not realm:
|
||||
raise ValueError("Missing Realm")
|
||||
|
||||
req = SignOutRequest(
|
||||
wa=action,
|
||||
wtrealm=realm,
|
||||
wreply=request.GET.get("wreply"),
|
||||
return SignOutRequest(
|
||||
wa=request.GET.get(WS_FED_QS_ACTION),
|
||||
wtrealm=request.GET.get(WS_FED_QS_REALM),
|
||||
wreply=request.GET.get(WS_FED_QS_REPLY),
|
||||
request=request,
|
||||
)
|
||||
|
||||
_, provider = req.get_app_provider()
|
||||
if not req.wreply:
|
||||
req.wreply = provider.acs_url
|
||||
reply = urlparse(req.wreply)
|
||||
def __post_init__(self, request: HttpRequest):
|
||||
if self.wa != WS_FED_ACTION_SIGN_OUT:
|
||||
raise ValueError("Invalid action")
|
||||
self.__post_init_resolve_realm(request)
|
||||
_, provider = self.get_app_provider()
|
||||
if not self.wreply:
|
||||
self.wreply = provider.acs_url
|
||||
if not self.wtrealm:
|
||||
raise ValueError("Missing Realm")
|
||||
reply = urlparse(self.wreply)
|
||||
configured = urlparse(provider.acs_url)
|
||||
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
|
||||
raise ValueError("Invalid wreply")
|
||||
return req
|
||||
|
||||
def __post_init_resolve_realm(self, request: HttpRequest):
|
||||
slug = request.resolver_match.kwargs.get("application_slug")
|
||||
if not slug:
|
||||
return
|
||||
app = get_object_or_404(Application, slug=slug)
|
||||
provider = get_object_or_404(WSFederationProvider, pk=app.provider_id)
|
||||
self.wtrealm = provider.audience
|
||||
|
||||
def get_app_provider(self):
|
||||
provider: WSFederationProvider = get_object_or_404(
|
||||
|
||||
@@ -5,11 +5,18 @@ from lxml import etree # nosec
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_cert, create_test_flow
|
||||
from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
|
||||
from authentik.enterprise.providers.ws_federation.models import (
|
||||
WSFederationProvider,
|
||||
WSFederationSAMLVersion,
|
||||
)
|
||||
from authentik.enterprise.providers.ws_federation.processors.assertion_saml11 import (
|
||||
NS_SAML11_ASSERTION,
|
||||
)
|
||||
from authentik.enterprise.providers.ws_federation.processors.constants import (
|
||||
NS_MAP,
|
||||
WS_FED_ACTION_SIGN_IN,
|
||||
WS_FED_POST_KEY_RESULT,
|
||||
WSS_TOKEN_TYPE_SAML11,
|
||||
)
|
||||
from authentik.enterprise.providers.ws_federation.processors.sign_in import (
|
||||
SignInProcessor,
|
||||
@@ -17,6 +24,7 @@ from authentik.enterprise.providers.ws_federation.processors.sign_in import (
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
|
||||
class TestWSFedSignIn(TestCase):
|
||||
@@ -55,7 +63,7 @@ class TestWSFedSignIn(TestCase):
|
||||
request,
|
||||
SignInRequest(
|
||||
wa=WS_FED_ACTION_SIGN_IN,
|
||||
wtrealm="",
|
||||
wtrealm=self.provider.audience,
|
||||
wreply="",
|
||||
wctx=None,
|
||||
),
|
||||
@@ -76,7 +84,7 @@ class TestWSFedSignIn(TestCase):
|
||||
request,
|
||||
SignInRequest(
|
||||
wa=WS_FED_ACTION_SIGN_IN,
|
||||
wtrealm="",
|
||||
wtrealm=self.provider.audience,
|
||||
wreply="",
|
||||
wctx=None,
|
||||
),
|
||||
@@ -96,3 +104,110 @@ class TestWSFedSignIn(TestCase):
|
||||
None,
|
||||
)
|
||||
ctx.verify(signature_node)
|
||||
|
||||
|
||||
class TestWSFedSignInSAML11(TestCase):
|
||||
# NS_MAP binds the "saml" prefix to the SAML 2.0 namespace; override it for SAML 1.1 lookups
|
||||
ns_map = {**NS_MAP, "saml": NS_SAML11_ASSERTION}
|
||||
|
||||
def setUp(self):
|
||||
self.flow = create_test_flow()
|
||||
self.cert = create_test_cert()
|
||||
self.provider = WSFederationProvider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=self.flow,
|
||||
signing_kp=self.cert,
|
||||
acs_url="https://t.goauthentik.io",
|
||||
audience="foo",
|
||||
saml_version=WSFederationSAMLVersion.SAML_1_1,
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
name=generate_id(), slug=generate_id(), provider=self.provider
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _get_token(self) -> str:
|
||||
request = self.factory.get("/", user=get_anonymous_user())
|
||||
proc = SignInProcessor(
|
||||
self.provider,
|
||||
request,
|
||||
SignInRequest(
|
||||
wa=WS_FED_ACTION_SIGN_IN,
|
||||
wtrealm=self.provider.audience,
|
||||
wreply="",
|
||||
wctx=None,
|
||||
),
|
||||
)
|
||||
return proc.response()[WS_FED_POST_KEY_RESULT]
|
||||
|
||||
def test_token_gen(self):
|
||||
token = self._get_token()
|
||||
root = lxml_from_string(token)
|
||||
|
||||
schema = etree.XMLSchema(
|
||||
etree.parse(source="schemas/ws-trust.xsd", parser=etree.XMLParser()) # nosec
|
||||
)
|
||||
self.assertTrue(schema.validate(etree=root), schema.error_log)
|
||||
|
||||
assertion = root.xpath("//*[local-name()='Assertion']")[0]
|
||||
self.assertEqual(assertion.tag, f"{{{NS_SAML11_ASSERTION}}}Assertion")
|
||||
self.assertEqual(assertion.attrib["MajorVersion"], "1")
|
||||
self.assertEqual(assertion.attrib["MinorVersion"], "1")
|
||||
self.assertIn("AssertionID", assertion.attrib)
|
||||
self.assertNotIn("ID", assertion.attrib)
|
||||
|
||||
assertion_schema = etree.XMLSchema(
|
||||
etree.parse( # nosec
|
||||
source="schemas/oasis-sstc-saml-schema-assertion-1.1.xsd",
|
||||
parser=etree.XMLParser(),
|
||||
)
|
||||
)
|
||||
self.assertTrue(assertion_schema.validate(etree=assertion), assertion_schema.error_log)
|
||||
|
||||
token_type = root.xpath("//t:TokenType", namespaces=self.ns_map)[0]
|
||||
self.assertEqual(token_type.text, WSS_TOKEN_TYPE_SAML11)
|
||||
|
||||
# SAML 1.1 uses NameIdentifier/AuthenticationStatement, not NameID/AuthnStatement
|
||||
self.assertEqual(len(assertion.xpath("//saml:NameIdentifier", namespaces=self.ns_map)), 1)
|
||||
self.assertEqual(
|
||||
len(assertion.xpath("//saml:AuthenticationStatement", namespaces=self.ns_map)), 1
|
||||
)
|
||||
|
||||
def test_signature(self):
|
||||
token = self._get_token()
|
||||
|
||||
root = lxml_from_string(token)
|
||||
xmlsec.tree.add_ids(root, ["AssertionID"])
|
||||
signature_nodes = root.xpath(
|
||||
"//*[local-name()='Assertion']/ds:Signature", namespaces=self.ns_map
|
||||
)
|
||||
self.assertEqual(len(signature_nodes), 1)
|
||||
|
||||
signature_node = signature_nodes[0]
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = xmlsec.Key.from_memory(
|
||||
self.cert.certificate_data,
|
||||
xmlsec.constants.KeyDataFormatCertPem,
|
||||
None,
|
||||
)
|
||||
ctx.verify(signature_node)
|
||||
|
||||
def test_attribute_statement(self):
|
||||
mapping = SAMLPropertyMapping.objects.create(
|
||||
name=generate_id(), saml_name="test-claim", expression="return 'test-value'"
|
||||
)
|
||||
self.provider.property_mappings.add(mapping)
|
||||
|
||||
token = self._get_token()
|
||||
root = lxml_from_string(token)
|
||||
|
||||
attributes = root.xpath("//saml:Attribute", namespaces=self.ns_map)
|
||||
self.assertEqual(len(attributes), 1)
|
||||
attribute = attributes[0]
|
||||
self.assertEqual(attribute.attrib["AttributeName"], "test-claim")
|
||||
self.assertIn("AttributeNamespace", attribute.attrib)
|
||||
self.assertNotIn("Name", attribute.attrib)
|
||||
self.assertNotIn("FriendlyName", attribute.attrib)
|
||||
values = attribute.xpath("saml:AttributeValue", namespaces=self.ns_map)
|
||||
self.assertEqual(len(values), 1)
|
||||
self.assertEqual(values[0].text, "test-value")
|
||||
|
||||
@@ -11,6 +11,11 @@ urlpatterns = [
|
||||
WSFedEntryView.as_view(),
|
||||
name="wsfed",
|
||||
),
|
||||
path(
|
||||
"<slug:application_slug>/",
|
||||
WSFedEntryView.as_view(),
|
||||
name="wsfed-app-specific",
|
||||
),
|
||||
# Metadata
|
||||
path(
|
||||
"<slug:application_slug>/metadata/",
|
||||
|
||||
@@ -7928,6 +7928,15 @@
|
||||
"title": "AuthnContextClassRef Property Mapping",
|
||||
"description": "Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate."
|
||||
},
|
||||
"saml_version": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1.1",
|
||||
"2.0"
|
||||
],
|
||||
"title": "Saml version",
|
||||
"description": "SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1."
|
||||
},
|
||||
"digest_algorithm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
6
packages/client-ts/src/apis/ProvidersApi.ts
generated
6
packages/client-ts/src/apis/ProvidersApi.ts
generated
@@ -214,6 +214,7 @@ import { type SAMLMetadata, SAMLMetadataFromJSON } from "../models/SAMLMetadata"
|
||||
import { type SAMLNameIDPolicyEnum } from "../models/SAMLNameIDPolicyEnum";
|
||||
import { type SAMLProvider, SAMLProviderFromJSON } from "../models/SAMLProvider";
|
||||
import { type SAMLProviderRequest, SAMLProviderRequestToJSON } from "../models/SAMLProviderRequest";
|
||||
import { type SamlVersionEnum } from "../models/SamlVersionEnum";
|
||||
import { type SCIMProvider, SCIMProviderFromJSON } from "../models/SCIMProvider";
|
||||
import { type SCIMProviderGroup, SCIMProviderGroupFromJSON } from "../models/SCIMProviderGroup";
|
||||
import {
|
||||
@@ -923,6 +924,7 @@ export interface ProvidersWsfedListRequest {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
propertyMappings?: Array<string>;
|
||||
samlVersion?: SamlVersionEnum;
|
||||
search?: string;
|
||||
sessionValidNotOnOrAfter?: string;
|
||||
signAssertion?: boolean;
|
||||
@@ -9432,6 +9434,10 @@ export class ProvidersApi extends runtime.BaseAPI {
|
||||
queryParameters["property_mappings"] = requestParameters["propertyMappings"];
|
||||
}
|
||||
|
||||
if (requestParameters["samlVersion"] != null) {
|
||||
queryParameters["saml_version"] = requestParameters["samlVersion"];
|
||||
}
|
||||
|
||||
if (requestParameters["search"] != null) {
|
||||
queryParameters["search"] = requestParameters["search"];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { DigestAlgorithmEnum } from "./DigestAlgorithmEnum";
|
||||
import { DigestAlgorithmEnumFromJSON, DigestAlgorithmEnumToJSON } from "./DigestAlgorithmEnum";
|
||||
import type { SAMLNameIDPolicyEnum } from "./SAMLNameIDPolicyEnum";
|
||||
import { SAMLNameIDPolicyEnumFromJSON, SAMLNameIDPolicyEnumToJSON } from "./SAMLNameIDPolicyEnum";
|
||||
import type { SamlVersionEnum } from "./SamlVersionEnum";
|
||||
import { SamlVersionEnumFromJSON, SamlVersionEnumToJSON } from "./SamlVersionEnum";
|
||||
import type { SignatureAlgorithmEnum } from "./SignatureAlgorithmEnum";
|
||||
import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
@@ -100,6 +102,12 @@ export interface PatchedWSFederationProviderRequest {
|
||||
* @memberof PatchedWSFederationProviderRequest
|
||||
*/
|
||||
authnContextClassRefMapping?: string | null;
|
||||
/**
|
||||
* SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.
|
||||
* @type {SamlVersionEnum}
|
||||
* @memberof PatchedWSFederationProviderRequest
|
||||
*/
|
||||
samlVersion?: SamlVersionEnum;
|
||||
/**
|
||||
*
|
||||
* @type {DigestAlgorithmEnum}
|
||||
@@ -193,6 +201,10 @@ export function PatchedWSFederationProviderRequestFromJSONTyped(
|
||||
json["authn_context_class_ref_mapping"] == null
|
||||
? undefined
|
||||
: json["authn_context_class_ref_mapping"],
|
||||
samlVersion:
|
||||
json["saml_version"] == null
|
||||
? undefined
|
||||
: SamlVersionEnumFromJSON(json["saml_version"]),
|
||||
digestAlgorithm:
|
||||
json["digest_algorithm"] == null
|
||||
? undefined
|
||||
@@ -240,6 +252,7 @@ export function PatchedWSFederationProviderRequestToJSONTyped(
|
||||
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],
|
||||
name_id_mapping: value["nameIdMapping"],
|
||||
authn_context_class_ref_mapping: value["authnContextClassRefMapping"],
|
||||
saml_version: SamlVersionEnumToJSON(value["samlVersion"]),
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
signature_algorithm: SignatureAlgorithmEnumToJSON(value["signatureAlgorithm"]),
|
||||
signing_kp: value["signingKp"],
|
||||
|
||||
57
packages/client-ts/src/models/SamlVersionEnum.ts
generated
Normal file
57
packages/client-ts/src/models/SamlVersionEnum.ts
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* authentik
|
||||
* Making authentication simple.
|
||||
*
|
||||
* The version of the OpenAPI document: 2026.8.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 SamlVersionEnum = {
|
||||
_11: "1.1",
|
||||
_20: "2.0",
|
||||
UnknownDefaultOpenApi: "11184809",
|
||||
} as const;
|
||||
export type SamlVersionEnum = (typeof SamlVersionEnum)[keyof typeof SamlVersionEnum];
|
||||
|
||||
export function instanceOfSamlVersionEnum(value: any): boolean {
|
||||
for (const key in SamlVersionEnum) {
|
||||
if (Object.prototype.hasOwnProperty.call(SamlVersionEnum, key)) {
|
||||
if (SamlVersionEnum[key as keyof typeof SamlVersionEnum] === value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function SamlVersionEnumFromJSON(json: any): SamlVersionEnum {
|
||||
return SamlVersionEnumFromJSONTyped(json, false);
|
||||
}
|
||||
|
||||
export function SamlVersionEnumFromJSONTyped(
|
||||
json: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): SamlVersionEnum {
|
||||
return json as SamlVersionEnum;
|
||||
}
|
||||
|
||||
export function SamlVersionEnumToJSON(value?: SamlVersionEnum | null): any {
|
||||
return value as any;
|
||||
}
|
||||
|
||||
export function SamlVersionEnumToJSONTyped(
|
||||
value: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): SamlVersionEnum {
|
||||
return value as SamlVersionEnum;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import type { DigestAlgorithmEnum } from "./DigestAlgorithmEnum";
|
||||
import { DigestAlgorithmEnumFromJSON, DigestAlgorithmEnumToJSON } from "./DigestAlgorithmEnum";
|
||||
import type { SAMLNameIDPolicyEnum } from "./SAMLNameIDPolicyEnum";
|
||||
import { SAMLNameIDPolicyEnumFromJSON, SAMLNameIDPolicyEnumToJSON } from "./SAMLNameIDPolicyEnum";
|
||||
import type { SamlVersionEnum } from "./SamlVersionEnum";
|
||||
import { SamlVersionEnumFromJSON, SamlVersionEnumToJSON } from "./SamlVersionEnum";
|
||||
import type { SignatureAlgorithmEnum } from "./SignatureAlgorithmEnum";
|
||||
import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
@@ -154,6 +156,12 @@ export interface WSFederationProvider {
|
||||
* @memberof WSFederationProvider
|
||||
*/
|
||||
authnContextClassRefMapping?: string | null;
|
||||
/**
|
||||
* SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.
|
||||
* @type {SamlVersionEnum}
|
||||
* @memberof WSFederationProvider
|
||||
*/
|
||||
samlVersion?: SamlVersionEnum;
|
||||
/**
|
||||
*
|
||||
* @type {DigestAlgorithmEnum}
|
||||
@@ -208,6 +216,12 @@ export interface WSFederationProvider {
|
||||
* @memberof WSFederationProvider
|
||||
*/
|
||||
readonly urlWsfed: string;
|
||||
/**
|
||||
* Get Issuer/EntityID URL
|
||||
* @type {string}
|
||||
* @memberof WSFederationProvider
|
||||
*/
|
||||
readonly urlIssuer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,6 +255,7 @@ export function instanceOfWSFederationProvider(value: object): value is WSFedera
|
||||
if (!("urlDownloadMetadata" in value) || value["urlDownloadMetadata"] === undefined)
|
||||
return false;
|
||||
if (!("urlWsfed" in value) || value["urlWsfed"] === undefined) return false;
|
||||
if (!("urlIssuer" in value) || value["urlIssuer"] === undefined) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -290,6 +305,10 @@ export function WSFederationProviderFromJSONTyped(
|
||||
json["authn_context_class_ref_mapping"] == null
|
||||
? undefined
|
||||
: json["authn_context_class_ref_mapping"],
|
||||
samlVersion:
|
||||
json["saml_version"] == null
|
||||
? undefined
|
||||
: SamlVersionEnumFromJSON(json["saml_version"]),
|
||||
digestAlgorithm:
|
||||
json["digest_algorithm"] == null
|
||||
? undefined
|
||||
@@ -309,6 +328,7 @@ export function WSFederationProviderFromJSONTyped(
|
||||
: SAMLNameIDPolicyEnumFromJSON(json["default_name_id_policy"]),
|
||||
urlDownloadMetadata: json["url_download_metadata"],
|
||||
urlWsfed: json["url_wsfed"],
|
||||
urlIssuer: json["url_issuer"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,6 +350,7 @@ export function WSFederationProviderToJSONTyped(
|
||||
| "meta_model_name"
|
||||
| "url_download_metadata"
|
||||
| "url_wsfed"
|
||||
| "url_issuer"
|
||||
> | null,
|
||||
ignoreDiscriminator: boolean = false,
|
||||
): any {
|
||||
@@ -350,6 +371,7 @@ export function WSFederationProviderToJSONTyped(
|
||||
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],
|
||||
name_id_mapping: value["nameIdMapping"],
|
||||
authn_context_class_ref_mapping: value["authnContextClassRefMapping"],
|
||||
saml_version: SamlVersionEnumToJSON(value["samlVersion"]),
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
signature_algorithm: SignatureAlgorithmEnumToJSON(value["signatureAlgorithm"]),
|
||||
signing_kp: value["signingKp"],
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { DigestAlgorithmEnum } from "./DigestAlgorithmEnum";
|
||||
import { DigestAlgorithmEnumFromJSON, DigestAlgorithmEnumToJSON } from "./DigestAlgorithmEnum";
|
||||
import type { SAMLNameIDPolicyEnum } from "./SAMLNameIDPolicyEnum";
|
||||
import { SAMLNameIDPolicyEnumFromJSON, SAMLNameIDPolicyEnumToJSON } from "./SAMLNameIDPolicyEnum";
|
||||
import type { SamlVersionEnum } from "./SamlVersionEnum";
|
||||
import { SamlVersionEnumFromJSON, SamlVersionEnumToJSON } from "./SamlVersionEnum";
|
||||
import type { SignatureAlgorithmEnum } from "./SignatureAlgorithmEnum";
|
||||
import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
@@ -100,6 +102,12 @@ export interface WSFederationProviderRequest {
|
||||
* @memberof WSFederationProviderRequest
|
||||
*/
|
||||
authnContextClassRefMapping?: string | null;
|
||||
/**
|
||||
* SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.
|
||||
* @type {SamlVersionEnum}
|
||||
* @memberof WSFederationProviderRequest
|
||||
*/
|
||||
samlVersion?: SamlVersionEnum;
|
||||
/**
|
||||
*
|
||||
* @type {DigestAlgorithmEnum}
|
||||
@@ -195,6 +203,10 @@ export function WSFederationProviderRequestFromJSONTyped(
|
||||
json["authn_context_class_ref_mapping"] == null
|
||||
? undefined
|
||||
: json["authn_context_class_ref_mapping"],
|
||||
samlVersion:
|
||||
json["saml_version"] == null
|
||||
? undefined
|
||||
: SamlVersionEnumFromJSON(json["saml_version"]),
|
||||
digestAlgorithm:
|
||||
json["digest_algorithm"] == null
|
||||
? undefined
|
||||
@@ -240,6 +252,7 @@ export function WSFederationProviderRequestToJSONTyped(
|
||||
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],
|
||||
name_id_mapping: value["nameIdMapping"],
|
||||
authn_context_class_ref_mapping: value["authnContextClassRefMapping"],
|
||||
saml_version: SamlVersionEnumToJSON(value["samlVersion"]),
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
signature_algorithm: SignatureAlgorithmEnumToJSON(value["signatureAlgorithm"]),
|
||||
signing_kp: value["signingKp"],
|
||||
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -761,6 +761,7 @@ export * from "./SSFProvider";
|
||||
export * from "./SSFProviderRequest";
|
||||
export * from "./SSFStream";
|
||||
export * from "./SSFStreamStatusEnum";
|
||||
export * from "./SamlVersionEnum";
|
||||
export * from "./Schedule";
|
||||
export * from "./ScheduleRequest";
|
||||
export * from "./ScopeMapping";
|
||||
|
||||
36
schema.yml
36
schema.yml
@@ -20412,6 +20412,14 @@ paths:
|
||||
format: uuid
|
||||
explode: true
|
||||
style: form
|
||||
- in: query
|
||||
name: saml_version
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SamlVersionEnum'
|
||||
description: |+
|
||||
SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.
|
||||
|
||||
- $ref: '#/components/parameters/QuerySearch'
|
||||
- in: query
|
||||
name: session_valid_not_on_or_after
|
||||
@@ -52034,6 +52042,12 @@ components:
|
||||
description: Configure how the AuthnContextClassRef value will be created.
|
||||
When left empty, the AuthnContextClassRef will be set based on which authentication
|
||||
methods the user used to authenticate.
|
||||
saml_version:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SamlVersionEnum'
|
||||
description: SAML assertion version to issue in the security token. Microsoft
|
||||
Entra ID and classic ADFS-style relying parties typically require SAML
|
||||
1.1.
|
||||
digest_algorithm:
|
||||
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
||||
signature_algorithm:
|
||||
@@ -56025,6 +56039,11 @@ components:
|
||||
- disabled
|
||||
- disabled_deleted
|
||||
type: string
|
||||
SamlVersionEnum:
|
||||
enum:
|
||||
- '1.1'
|
||||
- '2.0'
|
||||
type: string
|
||||
Schedule:
|
||||
type: object
|
||||
properties:
|
||||
@@ -59165,6 +59184,12 @@ components:
|
||||
description: Configure how the AuthnContextClassRef value will be created.
|
||||
When left empty, the AuthnContextClassRef will be set based on which authentication
|
||||
methods the user used to authenticate.
|
||||
saml_version:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SamlVersionEnum'
|
||||
description: SAML assertion version to issue in the security token. Microsoft
|
||||
Entra ID and classic ADFS-style relying parties typically require SAML
|
||||
1.1.
|
||||
digest_algorithm:
|
||||
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
||||
signature_algorithm:
|
||||
@@ -59198,6 +59223,10 @@ components:
|
||||
type: string
|
||||
description: Get WS-Fed url
|
||||
readOnly: true
|
||||
url_issuer:
|
||||
type: string
|
||||
description: Get Issuer/EntityID URL
|
||||
readOnly: true
|
||||
required:
|
||||
- assigned_application_name
|
||||
- assigned_application_slug
|
||||
@@ -59211,6 +59240,7 @@ components:
|
||||
- pk
|
||||
- reply_url
|
||||
- url_download_metadata
|
||||
- url_issuer
|
||||
- url_wsfed
|
||||
- verbose_name
|
||||
- verbose_name_plural
|
||||
@@ -59278,6 +59308,12 @@ components:
|
||||
description: Configure how the AuthnContextClassRef value will be created.
|
||||
When left empty, the AuthnContextClassRef will be set based on which authentication
|
||||
methods the user used to authenticate.
|
||||
saml_version:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SamlVersionEnum'
|
||||
description: SAML assertion version to issue in the security token. Microsoft
|
||||
Entra ID and classic ADFS-style relying parties typically require SAML
|
||||
1.1.
|
||||
digest_algorithm:
|
||||
$ref: '#/components/schemas/DigestAlgorithmEnum'
|
||||
signature_algorithm:
|
||||
|
||||
194
schemas/oasis-sstc-saml-schema-assertion-1.1.xsd
Normal file
194
schemas/oasis-sstc-saml-schema-assertion-1.1.xsd
Normal file
@@ -0,0 +1,194 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- edited with XML Spy v3.5 NT (http://www.xmlspy.com) by Phill Hallam-Baker (VeriSign Inc.) -->
|
||||
<schema targetNamespace="urn:oasis:names:tc:SAML:1.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" xmlns="http://www.w3.org/2001/XMLSchema" elementFormDefault="unqualified">
|
||||
<import namespace="http://www.w3.org/2000/09/xmldsig#" schemaLocation="xmldsig-core-schema.xsd"/>
|
||||
<annotation>
|
||||
<documentation>
|
||||
Document identifier: cs-sstc-schema-assertion-01
|
||||
Location: http://www.oasis-open.org/committees/security/docs/
|
||||
</documentation>
|
||||
</annotation>
|
||||
<simpleType name="IDType">
|
||||
<restriction base="string"/>
|
||||
</simpleType>
|
||||
<simpleType name="IDReferenceType">
|
||||
<restriction base="string"/>
|
||||
</simpleType>
|
||||
<simpleType name="DecisionType">
|
||||
<restriction base="string">
|
||||
<enumeration value="Permit"/>
|
||||
<enumeration value="Deny"/>
|
||||
<enumeration value="Indeterminate"/>
|
||||
</restriction>
|
||||
</simpleType>
|
||||
<element name="AssertionIDReference" type="saml:IDReferenceType"/>
|
||||
<element name="Assertion" type="saml:AssertionType"/>
|
||||
<complexType name="AssertionType">
|
||||
<sequence>
|
||||
<element ref="saml:Conditions" minOccurs="0"/>
|
||||
<element ref="saml:Advice" minOccurs="0"/>
|
||||
<choice maxOccurs="unbounded">
|
||||
<element ref="saml:Statement"/>
|
||||
<element ref="saml:SubjectStatement"/>
|
||||
<element ref="saml:AuthenticationStatement"/>
|
||||
<element ref="saml:AuthorizationDecisionStatement"/>
|
||||
<element ref="saml:AttributeStatement"/>
|
||||
</choice>
|
||||
<element ref="ds:Signature" minOccurs="0"/>
|
||||
</sequence>
|
||||
<attribute name="MajorVersion" type="integer" use="required"/>
|
||||
<attribute name="MinorVersion" type="integer" use="required"/>
|
||||
<attribute name="AssertionID" type="saml:IDType" use="required"/>
|
||||
<attribute name="Issuer" type="string" use="required"/>
|
||||
<attribute name="IssueInstant" type="dateTime" use="required"/>
|
||||
</complexType>
|
||||
<element name="Conditions" type="saml:ConditionsType"/>
|
||||
<complexType name="ConditionsType">
|
||||
<choice minOccurs="0" maxOccurs="unbounded">
|
||||
<element ref="saml:AudienceRestrictionCondition"/>
|
||||
<element ref="saml:Condition"/>
|
||||
</choice>
|
||||
<attribute name="NotBefore" type="dateTime" use="optional"/>
|
||||
<attribute name="NotOnOrAfter" type="dateTime" use="optional"/>
|
||||
</complexType>
|
||||
<element name="Condition" type="saml:ConditionAbstractType"/>
|
||||
<complexType name="ConditionAbstractType" abstract="true"/>
|
||||
<element name="AudienceRestrictionCondition" type="saml:AudienceRestrictionConditionType"/>
|
||||
<complexType name="AudienceRestrictionConditionType">
|
||||
<complexContent>
|
||||
<extension base="saml:ConditionAbstractType">
|
||||
<sequence>
|
||||
<element ref="saml:Audience" maxOccurs="unbounded"/>
|
||||
</sequence>
|
||||
</extension>
|
||||
</complexContent>
|
||||
</complexType>
|
||||
<element name="Audience" type="anyURI"/>
|
||||
<element name="Advice" type="saml:AdviceType"/>
|
||||
<complexType name="AdviceType">
|
||||
<choice minOccurs="0" maxOccurs="unbounded">
|
||||
<element ref="saml:AssertionIDReference"/>
|
||||
<element ref="saml:Assertion"/>
|
||||
<any namespace="##other" processContents="lax"/>
|
||||
</choice>
|
||||
</complexType>
|
||||
<element name="Statement" type="saml:StatementAbstractType"/>
|
||||
<complexType name="StatementAbstractType" abstract="true"/>
|
||||
<element name="SubjectStatement" type="saml:SubjectStatementAbstractType"/>
|
||||
<complexType name="SubjectStatementAbstractType" abstract="true">
|
||||
<complexContent>
|
||||
<extension base="saml:StatementAbstractType">
|
||||
<sequence>
|
||||
<element ref="saml:Subject"/>
|
||||
</sequence>
|
||||
</extension>
|
||||
</complexContent>
|
||||
</complexType>
|
||||
<element name="Subject" type="saml:SubjectType"/>
|
||||
<complexType name="SubjectType">
|
||||
<choice>
|
||||
<sequence>
|
||||
<element ref="saml:NameIdentifier"/>
|
||||
<element ref="saml:SubjectConfirmation" minOccurs="0"/>
|
||||
</sequence>
|
||||
<element ref="saml:SubjectConfirmation"/>
|
||||
</choice>
|
||||
</complexType>
|
||||
<element name="NameIdentifier" type="saml:NameIdentifierType"/>
|
||||
<complexType name="NameIdentifierType">
|
||||
<simpleContent>
|
||||
<extension base="string">
|
||||
<attribute name="NameQualifier" type="string" use="optional"/>
|
||||
<attribute name="Format" type="anyURI" use="optional"/>
|
||||
</extension>
|
||||
</simpleContent>
|
||||
</complexType>
|
||||
<element name="SubjectConfirmation" type="saml:SubjectConfirmationType"/>
|
||||
<complexType name="SubjectConfirmationType">
|
||||
<sequence>
|
||||
<element ref="saml:ConfirmationMethod" maxOccurs="unbounded"/>
|
||||
<element ref="saml:SubjectConfirmationData" minOccurs="0"/>
|
||||
<element ref="ds:KeyInfo" minOccurs="0"/>
|
||||
</sequence>
|
||||
</complexType>
|
||||
<element name="SubjectConfirmationData" type="anyType"/>
|
||||
<element name="ConfirmationMethod" type="anyURI"/>
|
||||
<element name="AuthenticationStatement" type="saml:AuthenticationStatementType"/>
|
||||
<complexType name="AuthenticationStatementType">
|
||||
<complexContent>
|
||||
<extension base="saml:SubjectStatementAbstractType">
|
||||
<sequence>
|
||||
<element ref="saml:SubjectLocality" minOccurs="0"/>
|
||||
<element ref="saml:AuthorityBinding" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</sequence>
|
||||
<attribute name="AuthenticationMethod" type="anyURI" use="required"/>
|
||||
<attribute name="AuthenticationInstant" type="dateTime" use="required"/>
|
||||
</extension>
|
||||
</complexContent>
|
||||
</complexType>
|
||||
<element name="SubjectLocality" type="saml:SubjectLocalityType"/>
|
||||
<complexType name="SubjectLocalityType">
|
||||
<attribute name="IPAddress" type="string" use="optional"/>
|
||||
<attribute name="DNSAddress" type="string" use="optional"/>
|
||||
</complexType>
|
||||
<element name="AuthorityBinding" type="saml:AuthorityBindingType"/>
|
||||
<complexType name="AuthorityBindingType">
|
||||
<attribute name="AuthorityKind" type="QName" use="required"/>
|
||||
<attribute name="Location" type="anyURI" use="required"/>
|
||||
<attribute name="Binding" type="anyURI" use="required"/>
|
||||
</complexType>
|
||||
<element name="AuthorizationDecisionStatement" type="saml:AuthorizationDecisionStatementType"/>
|
||||
<complexType name="AuthorizationDecisionStatementType">
|
||||
<complexContent>
|
||||
<extension base="saml:SubjectStatementAbstractType">
|
||||
<sequence>
|
||||
<element ref="saml:Action" maxOccurs="unbounded"/>
|
||||
<element ref="saml:Evidence" minOccurs="0"/>
|
||||
</sequence>
|
||||
<attribute name="Resource" type="anyURI" use="required"/>
|
||||
<attribute name="Decision" type="saml:DecisionType" use="required"/>
|
||||
</extension>
|
||||
</complexContent>
|
||||
</complexType>
|
||||
<element name="Action" type="saml:ActionType"/>
|
||||
<complexType name="ActionType">
|
||||
<simpleContent>
|
||||
<extension base="string">
|
||||
<attribute name="Namespace" type="anyURI"/>
|
||||
</extension>
|
||||
</simpleContent>
|
||||
</complexType>
|
||||
<element name="Evidence" type="saml:EvidenceType"/>
|
||||
<complexType name="EvidenceType">
|
||||
<choice maxOccurs="unbounded">
|
||||
<element ref="saml:AssertionIDReference"/>
|
||||
<element ref="saml:Assertion"/>
|
||||
</choice>
|
||||
</complexType>
|
||||
<element name="AttributeStatement" type="saml:AttributeStatementType"/>
|
||||
<complexType name="AttributeStatementType">
|
||||
<complexContent>
|
||||
<extension base="saml:SubjectStatementAbstractType">
|
||||
<sequence>
|
||||
<element ref="saml:Attribute" maxOccurs="unbounded"/>
|
||||
</sequence>
|
||||
</extension>
|
||||
</complexContent>
|
||||
</complexType>
|
||||
<element name="AttributeDesignator" type="saml:AttributeDesignatorType"/>
|
||||
<complexType name="AttributeDesignatorType">
|
||||
<attribute name="AttributeName" type="string" use="required"/>
|
||||
<attribute name="AttributeNamespace" type="anyURI" use="required"/>
|
||||
</complexType>
|
||||
<element name="Attribute" type="saml:AttributeType"/>
|
||||
<complexType name="AttributeType">
|
||||
<complexContent>
|
||||
<extension base="saml:AttributeDesignatorType">
|
||||
<sequence>
|
||||
<element ref="saml:AttributeValue" maxOccurs="unbounded"/>
|
||||
</sequence>
|
||||
</extension>
|
||||
</complexContent>
|
||||
</complexType>
|
||||
<element name="AttributeValue" type="anyType"/>
|
||||
</schema>
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
PropertymappingsApi,
|
||||
SAMLNameIDPolicyEnum,
|
||||
SAMLPropertyMapping,
|
||||
SamlVersionEnum,
|
||||
ValidationError,
|
||||
WSFederationProvider,
|
||||
} from "@goauthentik/api";
|
||||
@@ -40,6 +41,16 @@ import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const samlVersionAndLabel = [
|
||||
[
|
||||
SamlVersionEnum._11,
|
||||
msg("SAML 1.1 (required by Microsoft Entra ID / ADFS)", {
|
||||
id: "wsfed.saml-version.option.saml11",
|
||||
}),
|
||||
],
|
||||
[SamlVersionEnum._20, msg("SAML 2.0", { id: "wsfed.saml-version.option.saml20" })],
|
||||
];
|
||||
|
||||
const samlNameIDPolicyAndLabel = [
|
||||
[SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent, msg("Persistent")],
|
||||
[SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress, msg("Email address")],
|
||||
@@ -288,6 +299,33 @@ export function renderForm({
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("SAML assertion version", {
|
||||
id: "wsfed.saml-version.label",
|
||||
})}
|
||||
required
|
||||
name="samlVersion"
|
||||
>
|
||||
<select class="pf-c-form-control">
|
||||
${samlVersionAndLabel.map(
|
||||
([version, label]) => html`
|
||||
<option
|
||||
value=${version}
|
||||
?selected=${provider?.samlVersion === version}
|
||||
>
|
||||
${label}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.",
|
||||
{ id: "wsfed.saml-version.description" },
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Digest algorithm")}
|
||||
required
|
||||
|
||||
@@ -331,6 +331,19 @@ export class WSFederationProviderViewPage extends AKElement {
|
||||
value="${ifDefined(this.provider.wtrealm)}"
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("Issuer")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
value="${ifDefined(this.provider.urlIssuer)}"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
@@ -13,7 +13,8 @@ An authentik WS-Federation provider is typically created as part of an applicati
|
||||
5. On the **Configure WS-Federation Provider** page, provide a name for the provider, select an authorization flow, and the two required configuration settings:
|
||||
- **Reply URL**: Enter the application callback URL, where the token should be sent. This is the specific endpoint on an RP (application) where an Identity Provider (STS) sends the security token and authentication response after a successful login.
|
||||
- **Realm**: Enter the identifier (string) of the requesting realm; that is, the Relying Party (RP) or application receiving the token. Realm is similar to the SAML 2.0 Entity ID.
|
||||
6. Click **Create Application** to create both the application and the provider.
|
||||
6. Under **Advanced protocol settings**, optionally set the **SAML assertion version**. This defaults to SAML 2.0; select SAML 1.1 if the relying party requires it, such as Microsoft Entra ID or a classic ADFS-style integration.
|
||||
7. Click **Create Application** to create both the application and the provider.
|
||||
|
||||
## Export authentik WS-Federation provider metadata
|
||||
|
||||
|
||||
Reference in New Issue
Block a user