Skip to content

Commit

Permalink
Merge branch 'main' into dev
Browse files Browse the repository at this point in the history
* main:
  website/docs: add links and tweaks to existing docs on flow executors (#10340)
  sources/saml: fix pickle error, add saml auth tests (#10348)
  web: bump API Client version (#10351)
  core: applications api: add option to only list apps with launch url (#10336)
  website/integrations: minio: configure openid on web (#9874)
  website/docs: integrations: gitea: specify callback url (#10180)
  providers/saml: fix metadata import error handling (#10349)
  core, web: update translations (#10341)
  core: bump twilio from 9.2.2 to 9.2.3 (#10343)
  core: bump google-api-python-client from 2.135.0 to 2.136.0 (#10344)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#10339)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#10338)
  web: bump the storybook group in /web with 7 updates (#10263)
  web: lintpicking (#10212)
  • Loading branch information
kensternberg-authentik committed Jul 3, 2024
2 parents e70c5a1 + e4c8e30 commit de2ecfd
Show file tree
Hide file tree
Showing 74 changed files with 2,247 additions and 668 deletions.
18 changes: 18 additions & 0 deletions authentik/core/api/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ def _get_allowed_applications(
applications.append(application)
return applications

def _filter_applications_with_launch_url(self, pagined_apps: Iterator[Application]) -> list[Application]:
applications = []
for app in pagined_apps:
if app.get_launch_url():
applications.append(app)
return applications

@extend_schema(
parameters=[
OpenApiParameter(
Expand Down Expand Up @@ -204,6 +211,11 @@ def check_access(self, request: Request, slug: str) -> Response:
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
),
OpenApiParameter(
name="only_with_launch_url",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
),
]
)
def list(self, request: Request) -> Response:
Expand All @@ -216,6 +228,8 @@ def list(self, request: Request) -> Response:
if superuser_full_list and request.user.is_superuser:
return super().list(request)

only_with_launch_url = str(request.query_params.get("only_with_launch_url", "false")).lower()

queryset = self._filter_queryset_for_list(self.get_queryset())
paginator: Pagination = self.paginator
paginated_apps = paginator.paginate_queryset(queryset, request)
Expand Down Expand Up @@ -251,6 +265,10 @@ def list(self, request: Request) -> Response:
allowed_applications,
timeout=86400,
)

if only_with_launch_url == "true":
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)

serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)

Expand Down
5 changes: 5 additions & 0 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""authentik core models"""

from datetime import datetime
from functools import lru_cache
from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
Expand Down Expand Up @@ -475,6 +476,10 @@ def get_meta_icon(self) -> str | None:
return self.meta_icon.name
return self.meta_icon.url

# maxsize is set as 2 since that is called once to check
# if we should return applications with a launch URL
# and a second time to actually get the launch_url
@lru_cache(maxsize=2)
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
url = None
Expand Down
2 changes: 1 addition & 1 deletion authentik/providers/saml/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ def import_metadata(self, request: Request) -> Response:
except ValueError as exc: # pragma: no cover
LOGGER.warning(str(exc))
raise ValidationError(
_("Failed to import Metadata: {messages}".format_map({"message": str(exc)})),
_("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})),
) from None
return Response(status=204)

Expand Down
3 changes: 2 additions & 1 deletion authentik/sources/saml/processors/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.core.exceptions import SuspiciousOperation
from django.http import HttpRequest
from django.utils.timezone import now
from lxml import etree # nosec
from structlog.stdlib import get_logger

from authentik.core.models import (
Expand Down Expand Up @@ -240,7 +241,7 @@ def prepare_flow_manager(self) -> SourceFlowManager:
name_id.text,
delete_none_values(self.get_attributes()),
)
flow_manager.policy_context["saml_response"] = self._root
flow_manager.policy_context["saml_response"] = etree.tostring(self._root)
return flow_manager


Expand Down
9 changes: 9 additions & 0 deletions authentik/stages/user_write/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.db import transaction
from django.db.utils import IntegrityError, InternalError
from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject
from django.utils.translation import gettext as _
from rest_framework.exceptions import ValidationError

Expand Down Expand Up @@ -118,6 +119,14 @@ def update_user(self, user: User):
UserWriteStageView.write_attribute(user, key, value)
# User has this key already
elif hasattr(user, key):
if isinstance(user, SimpleLazyObject):
user._setup()
user = user._wrapped
attr = getattr(type(user), key)
if isinstance(attr, property):
if not attr.fset:
self.logger.info("discarding key", key=key)
continue
setattr(user, key, value)
# If none of the cases above matched, we have an attribute that the user doesn't have,
# has no setter for, is not a nested attributes value and as such is invalid
Expand Down
12 changes: 6 additions & 6 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2682,6 +2682,10 @@ paths:
name: name
schema:
type: string
- in: query
name: only_with_launch_url
schema:
type: boolean
- name: ordering
required: false
in: query
Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/test-saml-idp/saml20-sp-remote.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
/**
* SAML 2.0 remote SP metadata for SimpleSAMLphp.
*
* See: https://simplesamlphp.org/docs/stable/simplesamlphp-reference-sp-remote
*/

$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array(
'AssertionConsumerService' => getenv('SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE'),
'SingleLogoutService' => getenv('SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE'),
);

if (null != getenv('SIMPLESAMLPHP_SP_NAME_ID_FORMAT')) {
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('NameIDFormat' => getenv('SIMPLESAMLPHP_SP_NAME_ID_FORMAT')));
}

if (null != getenv('SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE')) {
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('simplesaml.nameidattribute' => getenv('SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE')));
}

if (null != getenv('SIMPLESAMLPHP_SP_SIGN_ASSERTION')) {
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('saml20.sign.assertion' => ('true' == getenv('SIMPLESAMLPHP_SP_SIGN_ASSERTION'))));
}
119 changes: 119 additions & 0 deletions tests/e2e/test_source_saml.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""test SAML Source"""

from pathlib import Path
from time import sleep
from typing import Any

Expand Down Expand Up @@ -88,8 +89,20 @@ def get_container_specs(self) -> dict[str, Any] | None:
interval=5 * 1_000 * 1_000_000,
start_period=1 * 1_000 * 1_000_000,
),
"volumes": {
str(
(Path(__file__).parent / Path("test-saml-idp/saml20-sp-remote.php")).absolute()
): {
"bind": "/var/www/simplesamlphp/metadata/saml20-sp-remote.php",
"mode": "ro",
}
},
"environment": {
"SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id",
"SIMPLESAMLPHP_SP_NAME_ID_FORMAT": (
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
),
"SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE": "email",
"SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": (
self.url("authentik_sources_saml:acs", source_slug=self.slug)
),
Expand Down Expand Up @@ -318,3 +331,109 @@ def test_idp_post_auto(self):
.exclude(pk=self.user.pk)
.first()
)

@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-source-authentication.yaml",
"default/flow-default-source-enrollment.yaml",
"default/flow-default-source-pre-authentication.yaml",
)
def test_idp_post_auto_enroll_auth(self):
"""test SAML Source With post binding (auto redirect)"""
# Bootstrap all needed objects
authentication_flow = Flow.objects.get(slug="default-source-authentication")
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
pre_authentication_flow = Flow.objects.get(slug="default-source-pre-authentication")
keypair = CertificateKeyPair.objects.create(
name=generate_id(),
certificate_data=IDP_CERT,
key_data=IDP_KEY,
)

source = SAMLSource.objects.create(
name=generate_id(),
slug=self.slug,
authentication_flow=authentication_flow,
enrollment_flow=enrollment_flow,
pre_authentication_flow=pre_authentication_flow,
issuer="entity-id",
sso_url=f"http://{self.host}:8080/simplesaml/saml2/idp/SSOService.php",
binding_type=SAMLBindingTypes.POST_AUTO,
signing_kp=keypair,
)
ident_stage = IdentificationStage.objects.first()
ident_stage.sources.set([source])
ident_stage.save()

self.driver.get(self.live_server_url)

flow_executor = self.get_shadow_root("ak-flow-executor")
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
wait = WebDriverWait(identification_stage, self.wait_timeout)

wait.until(
ec.presence_of_element_located(
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
)
)
identification_stage.find_element(
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
).click()

# Now we should be at the IDP, wait for the username field
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
self.driver.find_element(By.ID, "username").send_keys("user1")
self.driver.find_element(By.ID, "password").send_keys("user1pass")
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)

# Wait until we're logged in
self.wait_for_url(self.if_user_url("/library"))
self.driver.get(self.if_user_url("/settings"))

self.assert_user(
User.objects.exclude(username="akadmin")
.exclude(username__startswith="ak-outpost")
.exclude_anonymous()
.exclude(pk=self.user.pk)
.first()
)

# Clear all cookies and log in again
self.driver.delete_all_cookies()
self.driver.get(self.live_server_url)

flow_executor = self.get_shadow_root("ak-flow-executor")
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
wait = WebDriverWait(identification_stage, self.wait_timeout)

wait.until(
ec.presence_of_element_located(
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
)
)
identification_stage.find_element(
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
).click()

# Now we should be at the IDP, wait for the username field
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
self.driver.find_element(By.ID, "username").send_keys("user1")
self.driver.find_element(By.ID, "password").send_keys("user1pass")
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)

# Wait until we're logged in
self.wait_for_url(self.if_user_url("/library"))
self.driver.get(self.if_user_url("/settings"))

# sleep(999999)
self.assert_user(
User.objects.exclude(username="akadmin")
.exclude(username__startswith="ak-outpost")
.exclude_anonymous()
.exclude(pk=self.user.pk)
.first()
)
Loading

0 comments on commit de2ecfd

Please sign in to comment.