Merge branch 'main' into web/issue-4880-multi-select-limitations

* main:
  website/docs: add link to our example flows (#8052)
  providers/oauth2: offline access (#8026)
  web: bump API Client version (#8059)
  Update index.md (#8056)
  enterprise/providers/rac: add option to limit concurrent connections to endpoint (#8053)
  web: bump API Client version (#8058)
  enterprise/providers/rac: add alert that enterprise is required for RAC (#8057)
  enterprise/providers/rac: create authorize_application event when creating token (#8050)
This commit is contained in:
Ken Sternberg 2024-01-04 13:38:03 -08:00
commit f30a2530d9
50 changed files with 649 additions and 243 deletions

View file

@ -19,6 +19,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping
from authentik.enterprise.apps import EnterpriseConfig
from authentik.events.utils import sanitize_item from authentik.events.utils import sanitize_item
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.exec import PolicyTestSerializer from authentik.policies.api.exec import PolicyTestSerializer
@ -95,6 +96,7 @@ class PropertyMappingViewSet(
"description": subclass.__doc__, "description": subclass.__doc__,
"component": subclass().component, "component": subclass().component,
"model_name": subclass._meta.model_name, "model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
} }
) )
return Response(TypeCreateSerializer(data, many=True).data) return Response(TypeCreateSerializer(data, many=True).data)

View file

@ -16,6 +16,7 @@ from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
@ -113,6 +114,7 @@ class ProviderViewSet(
"description": subclass.__doc__, "description": subclass.__doc__,
"component": subclass().component, "component": subclass().component,
"model_name": subclass._meta.model_name, "model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
} }
) )
data.append( data.append(

View file

@ -5,7 +5,7 @@ from django.db.models import Model
from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.plumbing import build_basic_type from drf_spectacular.plumbing import build_basic_type
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import CharField, IntegerField, JSONField from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField
from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError
@ -74,6 +74,7 @@ class TypeCreateSerializer(PassiveSerializer):
description = CharField(required=True) description = CharField(required=True)
component = CharField(required=True) component = CharField(required=True)
model_name = CharField(required=True) model_name = CharField(required=True)
requires_enterprise = BooleanField(default=False)
class CacheSerializer(PassiveSerializer): class CacheSerializer(PassiveSerializer):

View file

@ -2,9 +2,11 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
@ -20,6 +22,18 @@ from authentik.enterprise.models import License, LicenseKey
from authentik.root.install_id import get_install_id from authentik.root.install_id import get_install_id
class EnterpriseRequiredMixin:
"""Mixin to validate that a valid enterprise license
exists before allowing to safe the object"""
def validate(self, attrs: dict) -> dict:
"""Check that a valid license exists"""
total = LicenseKey.get_total()
if not total.is_valid():
raise ValidationError(_("Enterprise is required to create/update this object."))
return super().validate(attrs)
class LicenseSerializer(ModelSerializer): class LicenseSerializer(ModelSerializer):
"""License Serializer""" """License Serializer"""

View file

@ -2,7 +2,11 @@
from authentik.blueprints.apps import ManagedAppConfig from authentik.blueprints.apps import ManagedAppConfig
class AuthentikEnterpriseConfig(ManagedAppConfig): class EnterpriseConfig(ManagedAppConfig):
"""Base app config for all enterprise apps"""
class AuthentikEnterpriseConfig(EnterpriseConfig):
"""Enterprise app config""" """Enterprise app config"""
name = "authentik.enterprise" name = "authentik.enterprise"

View file

@ -15,6 +15,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
from authentik.enterprise.providers.rac.models import Endpoint from authentik.enterprise.providers.rac.models import Endpoint
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -28,7 +29,7 @@ def user_endpoint_cache_key(user_pk: str) -> str:
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}" return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
class EndpointSerializer(ModelSerializer): class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
"""Endpoint Serializer""" """Endpoint Serializer"""
provider_obj = RACProviderSerializer(source="provider", read_only=True) provider_obj = RACProviderSerializer(source="provider", read_only=True)
@ -59,6 +60,7 @@ class EndpointSerializer(ModelSerializer):
"property_mappings", "property_mappings",
"auth_mode", "auth_mode",
"launch_url", "launch_url",
"maximum_connections",
] ]

View file

@ -5,10 +5,11 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField from authentik.core.api.utils import JSONDictField
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.rac.models import RACPropertyMapping from authentik.enterprise.providers.rac.models import RACPropertyMapping
class RACPropertyMappingSerializer(PropertyMappingSerializer): class RACPropertyMappingSerializer(EnterpriseRequiredMixin, PropertyMappingSerializer):
"""RACPropertyMapping Serializer""" """RACPropertyMapping Serializer"""
static_settings = JSONDictField() static_settings = JSONDictField()

View file

@ -4,10 +4,11 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.rac.models import RACProvider from authentik.enterprise.providers.rac.models import RACProvider
class RACProviderSerializer(ProviderSerializer): class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""RACProvider Serializer""" """RACProvider Serializer"""
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all") outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")

View file

@ -1,8 +1,8 @@
"""RAC app config""" """RAC app config"""
from authentik.blueprints.apps import ManagedAppConfig from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseProviderRAC(ManagedAppConfig): class AuthentikEnterpriseProviderRAC(EnterpriseConfig):
"""authentik enterprise rac app config""" """authentik enterprise rac app config"""
name = "authentik.enterprise.providers.rac" name = "authentik.enterprise.providers.rac"

View file

@ -0,0 +1,17 @@
# Generated by Django 5.0 on 2024-01-03 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_rac", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="endpoint",
name="maximum_connections",
field=models.IntegerField(default=1),
),
]

View file

@ -35,7 +35,7 @@ class AuthenticationMode(models.TextChoices):
class RACProvider(Provider): class RACProvider(Provider):
"""Remotely access computers/servers""" """Remotely access computers/servers via RDP/SSH/VNC."""
settings = models.JSONField(default=dict) settings = models.JSONField(default=dict)
auth_mode = models.TextField( auth_mode = models.TextField(
@ -81,6 +81,7 @@ class Endpoint(SerializerModel, PolicyBindingModel):
settings = models.JSONField(default=dict) settings = models.JSONField(default=dict)
auth_mode = models.TextField(choices=AuthenticationMode.choices) auth_mode = models.TextField(choices=AuthenticationMode.choices)
provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE) provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE)
maximum_connections = models.IntegerField(default=1)
property_mappings = models.ManyToManyField( property_mappings = models.ManyToManyField(
"authentik_core.PropertyMapping", default=None, blank=True "authentik_core.PropertyMapping", default=None, blank=True

View file

@ -81,6 +81,7 @@ class TestEndpointsAPI(APITestCase):
}, },
"protocol": "rdp", "protocol": "rdp",
"host": self.allowed.host, "host": self.allowed.host,
"maximum_connections": 1,
"settings": {}, "settings": {},
"property_mappings": [], "property_mappings": [],
"auth_mode": "", "auth_mode": "",
@ -131,6 +132,7 @@ class TestEndpointsAPI(APITestCase):
}, },
"protocol": "rdp", "protocol": "rdp",
"host": self.allowed.host, "host": self.allowed.host,
"maximum_connections": 1,
"settings": {}, "settings": {},
"property_mappings": [], "property_mappings": [],
"auth_mode": "", "auth_mode": "",
@ -158,6 +160,7 @@ class TestEndpointsAPI(APITestCase):
}, },
"protocol": "rdp", "protocol": "rdp",
"host": self.denied.host, "host": self.denied.host,
"maximum_connections": 1,
"settings": {}, "settings": {},
"property_mappings": [], "property_mappings": [],
"auth_mode": "", "auth_mode": "",

View file

@ -5,11 +5,13 @@ from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _
from authentik.core.models import Application, AuthenticatedSession from authentik.core.models import Application, AuthenticatedSession
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import InterfaceView
from authentik.enterprise.policy import EnterprisePolicyAccessView from authentik.enterprise.policy import EnterprisePolicyAccessView
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import RedirectChallenge from authentik.flows.challenge import RedirectChallenge
from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
@ -43,6 +45,7 @@ class RACStartView(EnterprisePolicyAccessView):
plan.insert_stage( plan.insert_stage(
in_memory_stage( in_memory_stage(
RACFinalStage, RACFinalStage,
application=self.application,
endpoint=self.endpoint, endpoint=self.endpoint,
provider=self.provider, provider=self.provider,
) )
@ -77,29 +80,51 @@ class RACInterface(InterfaceView):
class RACFinalStage(RedirectStage): class RACFinalStage(RedirectStage):
"""RAC Connection final stage, set the connection token in the stage""" """RAC Connection final stage, set the connection token in the stage"""
endpoint: Endpoint
provider: RACProvider
application: Application
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
endpoint: Endpoint = self.executor.current_stage.endpoint self.endpoint = self.executor.current_stage.endpoint
engine = PolicyEngine(endpoint, self.request.user, self.request) self.provider = self.executor.current_stage.provider
self.application = self.executor.current_stage.application
# Check policies bound to endpoint directly
engine = PolicyEngine(self.endpoint, self.request.user, self.request)
engine.use_cache = False engine.use_cache = False
engine.build() engine.build()
passing = engine.result passing = engine.result
if not passing.passing: if not passing.passing:
return self.executor.stage_invalid(", ".join(passing.messages)) return self.executor.stage_invalid(", ".join(passing.messages))
# Check if we're already at the maximum connection limit
all_tokens = ConnectionToken.filter_not_expired(
endpoint=self.endpoint,
).exclude(endpoint__maximum_connections__lte=-1)
if all_tokens.count() >= self.endpoint.maximum_connections:
msg = [_("Maximum connection limit reached.")]
# Check if any other tokens exist for the current user, and inform them
# they are already connected
if all_tokens.filter(session__user=self.request.user).exists():
msg.append(_("(You are already connected in another tab/window)"))
return self.executor.stage_invalid(" ".join(msg))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> RedirectChallenge: def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
endpoint: Endpoint = self.executor.current_stage.endpoint
provider: RACProvider = self.executor.current_stage.provider
token = ConnectionToken.objects.create( token = ConnectionToken.objects.create(
provider=provider, provider=self.provider,
endpoint=endpoint, endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}), settings=self.executor.plan.context.get("connection_settings", {}),
session=AuthenticatedSession.objects.filter( session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key session_key=self.request.session.session_key
).first(), ).first(),
expires=now() + timedelta_from_string(provider.connection_expiry), expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True, expiring=True,
) )
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.application,
flow=self.executor.plan.flow_pk,
endpoint=self.endpoint.name,
).from_http(self.request)
setattr( setattr(
self.executor.current_stage, self.executor.current_stage,
"destination", "destination",

View file

@ -20,6 +20,7 @@ from authentik.core.models import (
User, User,
UserSourceConnection, UserSourceConnection,
) )
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.events.models import Event, EventAction, Notification from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict from authentik.events.utils import model_to_dict
from authentik.flows.models import FlowToken, Stage from authentik.flows.models import FlowToken, Stage
@ -54,6 +55,7 @@ IGNORED_MODELS = (
SCIMUser, SCIMUser,
SCIMGroup, SCIMGroup,
Reputation, Reputation,
ConnectionToken,
) )

View file

@ -7,8 +7,8 @@ GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
GRANT_TYPE_PASSWORD = "password" # nosec GRANT_TYPE_PASSWORD = "password" # nosec
GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
CLIENT_ASSERTION_TYPE = "client_assertion_type"
CLIENT_ASSERTION = "client_assertion" CLIENT_ASSERTION = "client_assertion"
CLIENT_ASSERTION_TYPE = "client_assertion_type"
CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
PROMPT_NONE = "none" PROMPT_NONE = "none"
@ -18,9 +18,9 @@ PROMPT_LOGIN = "login"
SCOPE_OPENID = "openid" SCOPE_OPENID = "openid"
SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_PROFILE = "profile"
SCOPE_OPENID_EMAIL = "email" SCOPE_OPENID_EMAIL = "email"
SCOPE_OFFLINE_ACCESS = "offline_access"
# https://www.iana.org/assignments/oauth-parameters/\ # https://www.iana.org/assignments/oauth-parameters/auth-parameters.xhtml#pkce-code-challenge-method
# oauth-parameters.xhtml#pkce-code-challenge-method
PKCE_METHOD_PLAIN = "plain" PKCE_METHOD_PLAIN = "plain"
PKCE_METHOD_S256 = "S256" PKCE_METHOD_S256 = "S256"
@ -36,6 +36,12 @@ SCOPE_GITHUB_USER_READ = "read:user"
SCOPE_GITHUB_USER_EMAIL = "user:email" SCOPE_GITHUB_USER_EMAIL = "user:email"
# Read info about teams # Read info about teams
SCOPE_GITHUB_ORG_READ = "read:org" SCOPE_GITHUB_ORG_READ = "read:org"
SCOPE_GITHUB = {
SCOPE_GITHUB_USER,
SCOPE_GITHUB_USER_READ,
SCOPE_GITHUB_USER_EMAIL,
SCOPE_GITHUB_ORG_READ,
}
ACR_AUTHENTIK_DEFAULT = "goauthentik.io/providers/oauth2/default" ACR_AUTHENTIK_DEFAULT = "goauthentik.io/providers/oauth2/default"

View file

@ -5,6 +5,7 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -18,6 +19,7 @@ from authentik.providers.oauth2.models import (
AuthorizationCode, AuthorizationCode,
GrantTypes, GrantTypes,
OAuth2Provider, OAuth2Provider,
ScopeMapping,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams
@ -172,14 +174,24 @@ class TestAuthorize(OAuthTestCase):
) )
OAuthAuthorizationParams.from_request(request) OAuthAuthorizationParams.from_request(request)
@apply_blueprint("system/providers-oauth2.yaml")
def test_response_type(self): def test_response_type(self):
"""test response_type""" """test response_type"""
OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name=generate_id(), name=generate_id(),
client_id="test", client_id="test",
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid/Foo", redirect_uris="http://local.invalid/Foo",
) )
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
request = self.factory.get( request = self.factory.get(
"/", "/",
data={ data={
@ -292,6 +304,7 @@ class TestAuthorize(OAuthTestCase):
delta=5, delta=5,
) )
@apply_blueprint("system/providers-oauth2.yaml")
def test_full_implicit(self): def test_full_implicit(self):
"""Test full authorization""" """Test full authorization"""
flow = create_test_flow() flow = create_test_flow()
@ -302,6 +315,15 @@ class TestAuthorize(OAuthTestCase):
redirect_uris="http://localhost", redirect_uris="http://localhost",
signing_key=self.keypair, signing_key=self.keypair,
) )
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
Application.objects.create(name="app", slug="app", provider=provider) Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id() state = generate_id()
user = create_test_admin_user() user = create_test_admin_user()
@ -409,6 +431,7 @@ class TestAuthorize(OAuthTestCase):
delta=5, delta=5,
) )
@apply_blueprint("system/providers-oauth2.yaml")
def test_full_form_post_id_token(self): def test_full_form_post_id_token(self):
"""Test full authorization (form_post response)""" """Test full authorization (form_post response)"""
flow = create_test_flow() flow = create_test_flow()
@ -419,6 +442,15 @@ class TestAuthorize(OAuthTestCase):
redirect_uris="http://localhost", redirect_uris="http://localhost",
signing_key=self.keypair, signing_key=self.keypair,
) )
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
state = generate_id() state = generate_id()
user = create_test_admin_user() user = create_test_admin_user()
@ -440,6 +472,7 @@ class TestAuthorize(OAuthTestCase):
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
) )
token: AccessToken = AccessToken.objects.filter(user=user).first() token: AccessToken = AccessToken.objects.filter(user=user).first()
self.assertIsNotNone(token)
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {

View file

@ -6,6 +6,7 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -21,6 +22,7 @@ from authentik.providers.oauth2.models import (
AuthorizationCode, AuthorizationCode,
OAuth2Provider, OAuth2Provider,
RefreshToken, RefreshToken,
ScopeMapping,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.token import TokenParams from authentik.providers.oauth2.views.token import TokenParams
@ -136,21 +138,20 @@ class TestToken(OAuthTestCase):
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
) )
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
refresh: RefreshToken = RefreshToken.objects.filter(user=user, provider=provider).first()
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
"access_token": access.token, "access_token": access.token,
"refresh_token": refresh.token,
"token_type": TOKEN_TYPE, "token_type": TOKEN_TYPE,
"expires_in": 3600, "expires_in": 3600,
"id_token": provider.encode( "id_token": provider.encode(
refresh.id_token.to_dict(), access.id_token.to_dict(),
), ),
}, },
) )
self.validate_jwt(access, provider) self.validate_jwt(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view(self): def test_refresh_token_view(self):
"""test request param""" """test request param"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
@ -159,6 +160,16 @@ class TestToken(OAuthTestCase):
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
signing_key=self.keypair, signing_key=self.keypair,
) )
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
"goauthentik.io/providers/oauth2/scope-offline_access",
]
)
)
# Needs to be assigned to an application for iss to be set # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider
self.app.save() self.app.save()
@ -170,6 +181,7 @@ class TestToken(OAuthTestCase):
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(), auth_time=timezone.now(),
_scope="offline_access",
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -201,6 +213,7 @@ class TestToken(OAuthTestCase):
) )
self.validate_jwt(access, provider) self.validate_jwt(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view_invalid_origin(self): def test_refresh_token_view_invalid_origin(self):
"""test request param""" """test request param"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
@ -209,6 +222,16 @@ class TestToken(OAuthTestCase):
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
signing_key=self.keypair, signing_key=self.keypair,
) )
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
"goauthentik.io/providers/oauth2/scope-offline_access",
]
)
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create( token: RefreshToken = RefreshToken.objects.create(
@ -217,6 +240,7 @@ class TestToken(OAuthTestCase):
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(), auth_time=timezone.now(),
_scope="offline_access",
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -247,6 +271,7 @@ class TestToken(OAuthTestCase):
}, },
) )
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_revoke(self): def test_refresh_token_revoke(self):
"""test request param""" """test request param"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
@ -255,6 +280,16 @@ class TestToken(OAuthTestCase):
redirect_uris="http://testserver", redirect_uris="http://testserver",
signing_key=self.keypair, signing_key=self.keypair,
) )
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
"goauthentik.io/providers/oauth2/scope-offline_access",
]
)
)
# Needs to be assigned to an application for iss to be set # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider
self.app.save() self.app.save()
@ -266,6 +301,7 @@ class TestToken(OAuthTestCase):
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(), auth_time=timezone.now(),
_scope="offline_access",
) )
# Create initial refresh token # Create initial refresh token
response = self.client.post( response = self.client.post(

View file

@ -10,7 +10,7 @@ from authentik.providers.oauth2.views.token import TokenView
github_urlpatterns = [ github_urlpatterns = [
path( path(
"login/oauth/authorize", "login/oauth/authorize",
AuthorizationFlowInitView.as_view(), AuthorizationFlowInitView.as_view(github_compat=True),
name="github-authorize", name="github-authorize",
), ),
path( path(

View file

@ -1,5 +1,5 @@
"""authentik OAuth2 Authorization views""" """authentik OAuth2 Authorization views"""
from dataclasses import dataclass, field from dataclasses import InitVar, dataclass, field
from datetime import timedelta from datetime import timedelta
from hashlib import sha256 from hashlib import sha256
from json import dumps from json import dumps
@ -41,6 +41,8 @@ from authentik.providers.oauth2.constants import (
PROMPT_CONSENT, PROMPT_CONSENT,
PROMPT_LOGIN, PROMPT_LOGIN,
PROMPT_NONE, PROMPT_NONE,
SCOPE_GITHUB,
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
TOKEN_TYPE, TOKEN_TYPE,
) )
@ -66,7 +68,6 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage
from authentik.stages.consent.stage import ( from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
ConsentStageView,
) )
LOGGER = get_logger() LOGGER = get_logger()
@ -86,7 +87,7 @@ class OAuthAuthorizationParams:
redirect_uri: str redirect_uri: str
response_type: str response_type: str
response_mode: Optional[str] response_mode: Optional[str]
scope: list[str] scope: set[str]
state: str state: str
nonce: Optional[str] nonce: Optional[str]
prompt: set[str] prompt: set[str]
@ -101,8 +102,10 @@ class OAuthAuthorizationParams:
code_challenge: Optional[str] = None code_challenge: Optional[str] = None
code_challenge_method: Optional[str] = None code_challenge_method: Optional[str] = None
github_compat: InitVar[bool] = False
@staticmethod @staticmethod
def from_request(request: HttpRequest) -> "OAuthAuthorizationParams": def from_request(request: HttpRequest, github_compat=False) -> "OAuthAuthorizationParams":
""" """
Get all the params used by the Authorization Code Flow Get all the params used by the Authorization Code Flow
(and also for the Implicit and Hybrid). (and also for the Implicit and Hybrid).
@ -154,7 +157,7 @@ class OAuthAuthorizationParams:
response_type=response_type, response_type=response_type,
response_mode=response_mode, response_mode=response_mode,
grant_type=grant_type, grant_type=grant_type,
scope=query_dict.get("scope", "").split(), scope=set(query_dict.get("scope", "").split()),
state=state, state=state,
nonce=query_dict.get("nonce"), nonce=query_dict.get("nonce"),
prompt=ALLOWED_PROMPT_PARAMS.intersection(set(query_dict.get("prompt", "").split())), prompt=ALLOWED_PROMPT_PARAMS.intersection(set(query_dict.get("prompt", "").split())),
@ -162,9 +165,10 @@ class OAuthAuthorizationParams:
max_age=int(max_age) if max_age else None, max_age=int(max_age) if max_age else None,
code_challenge=query_dict.get("code_challenge"), code_challenge=query_dict.get("code_challenge"),
code_challenge_method=query_dict.get("code_challenge_method", "plain"), code_challenge_method=query_dict.get("code_challenge_method", "plain"),
github_compat=github_compat,
) )
def __post_init__(self): def __post_init__(self, github_compat=False):
self.provider: OAuth2Provider = OAuth2Provider.objects.filter( self.provider: OAuth2Provider = OAuth2Provider.objects.filter(
client_id=self.client_id client_id=self.client_id
).first() ).first()
@ -172,7 +176,7 @@ class OAuthAuthorizationParams:
LOGGER.warning("Invalid client identifier", client_id=self.client_id) LOGGER.warning("Invalid client identifier", client_id=self.client_id)
raise ClientIdError(client_id=self.client_id) raise ClientIdError(client_id=self.client_id)
self.check_redirect_uri() self.check_redirect_uri()
self.check_scope() self.check_scope(github_compat)
self.check_nonce() self.check_nonce()
self.check_code_challenge() self.check_code_challenge()
@ -199,8 +203,8 @@ class OAuthAuthorizationParams:
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls): if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
LOGGER.warning( LOGGER.warning(
"Invalid redirect uri (regex comparison)", "Invalid redirect uri (regex comparison)",
redirect_uri=self.redirect_uri, redirect_uri_given=self.redirect_uri,
expected=allowed_redirect_urls, redirect_uri_expected=allowed_redirect_urls,
) )
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
except RegexError as exc: except RegexError as exc:
@ -208,8 +212,8 @@ class OAuthAuthorizationParams:
if not any(x == self.redirect_uri for x in allowed_redirect_urls): if not any(x == self.redirect_uri for x in allowed_redirect_urls):
LOGGER.warning( LOGGER.warning(
"Invalid redirect uri (strict comparison)", "Invalid redirect uri (strict comparison)",
redirect_uri=self.redirect_uri, redirect_uri_given=self.redirect_uri,
expected=allowed_redirect_urls, redirect_uri_expected=allowed_redirect_urls,
) )
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
if self.request: if self.request:
@ -217,24 +221,50 @@ class OAuthAuthorizationParams:
self.redirect_uri, "request_not_supported", self.grant_type, self.state self.redirect_uri, "request_not_supported", self.grant_type, self.state
) )
def check_scope(self): def check_scope(self, github_compat=False):
"""Ensure openid scope is set in Hybrid flows, or when requesting an id_token""" """Ensure openid scope is set in Hybrid flows, or when requesting an id_token"""
if len(self.scope) == 0:
default_scope_names = set( default_scope_names = set(
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
"scope_name", flat=True "scope_name", flat=True
) )
) )
if len(self.scope) == 0:
self.scope = default_scope_names self.scope = default_scope_names
LOGGER.info( LOGGER.info(
"No scopes requested, defaulting to all configured scopes", scopes=self.scope "No scopes requested, defaulting to all configured scopes", scopes=self.scope
) )
scopes_to_check = self.scope
if github_compat:
scopes_to_check = self.scope - SCOPE_GITHUB
if not scopes_to_check.issubset(default_scope_names):
LOGGER.info(
"Application requested scopes not configured, setting to overlap",
scope_allowed=default_scope_names,
scope_given=self.scope,
)
self.scope = self.scope.intersection(default_scope_names)
if SCOPE_OPENID not in self.scope and ( if SCOPE_OPENID not in self.scope and (
self.grant_type == GrantTypes.HYBRID self.grant_type == GrantTypes.HYBRID
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN] or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
): ):
LOGGER.warning("Missing 'openid' scope.") LOGGER.warning("Missing 'openid' scope.")
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type, self.state) raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type, self.state)
if SCOPE_OFFLINE_ACCESS in self.scope:
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if PROMPT_CONSENT not in self.prompt:
raise AuthorizeError(
self.redirect_uri, "consent_required", self.grant_type, self.state
)
if self.response_type not in [
ResponseTypes.CODE,
ResponseTypes.CODE_TOKEN,
ResponseTypes.CODE_ID_TOKEN,
ResponseTypes.CODE_ID_TOKEN_TOKEN,
]:
# offline_access requires a response type that has some sort of token
# Spec says to ignore the scope when the response_type wouldn't result
# in an authorization code being generated
self.scope.remove(SCOPE_OFFLINE_ACCESS)
def check_nonce(self): def check_nonce(self):
"""Nonce parameter validation.""" """Nonce parameter validation."""
@ -297,6 +327,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow""" """OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams params: OAuthAuthorizationParams
# Enable GitHub compatibility (only allow for scopes which are handled
# differently for github compat)
github_compat = False
def pre_permission_check(self): def pre_permission_check(self):
"""Check prompt parameter before checking permission/authentication, """Check prompt parameter before checking permission/authentication,
@ -305,7 +338,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
if len(self.request.GET) < 1: if len(self.request.GET) < 1:
raise Http404 raise Http404
try: try:
self.params = OAuthAuthorizationParams.from_request(self.request) self.params = OAuthAuthorizationParams.from_request(
self.request, github_compat=self.github_compat
)
except AuthorizeError as error: except AuthorizeError as error:
LOGGER.warning(error.description, redirect_uri=error.redirect_uri) LOGGER.warning(error.description, redirect_uri=error.redirect_uri)
raise RequestValidationError(error.get_response(self.request)) raise RequestValidationError(error.get_response(self.request))
@ -402,7 +437,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
# OpenID clients can specify a `prompt` parameter, and if its set to consent we # OpenID clients can specify a `prompt` parameter, and if its set to consent we
# need to inject a consent stage # need to inject a consent stage
if PROMPT_CONSENT in self.params.prompt: if PROMPT_CONSENT in self.params.prompt:
if not any(isinstance(x.stage, ConsentStageView) for x in plan.bindings): if not any(isinstance(x.stage, ConsentStage) for x in plan.bindings):
# Plan does not have any consent stage, so we add an in-memory one # Plan does not have any consent stage, so we add an in-memory one
stage = ConsentStage( stage = ConsentStage(
name="OAuth2 Provider In-memory consent stage", name="OAuth2 Provider In-memory consent stage",

View file

@ -41,6 +41,7 @@ from authentik.providers.oauth2.constants import (
GRANT_TYPE_PASSWORD, GRANT_TYPE_PASSWORD,
GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_REFRESH_TOKEN,
PKCE_METHOD_S256, PKCE_METHOD_S256,
SCOPE_OFFLINE_ACCESS,
TOKEN_TYPE, TOKEN_TYPE,
) )
from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError
@ -459,7 +460,7 @@ class TokenView(View):
op="authentik.providers.oauth2.post.response", op="authentik.providers.oauth2.post.response",
): ):
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
LOGGER.debug("Converting authorization code to refresh token") LOGGER.debug("Converting authorization code to access token")
return TokenResponse(self.create_code_response()) return TokenResponse(self.create_code_response())
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
LOGGER.debug("Refreshing refresh token") LOGGER.debug("Refreshing refresh token")
@ -496,6 +497,16 @@ class TokenView(View):
) )
access_token.save() access_token.save()
response = {
"access_token": access_token.token,
"token_type": TOKEN_TYPE,
"expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds()
),
"id_token": access_token.id_token.to_jwt(self.provider),
}
if SCOPE_OFFLINE_ACCESS in self.params.authorization_code.scope:
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
refresh_token = RefreshToken( refresh_token = RefreshToken(
user=self.params.authorization_code.user, user=self.params.authorization_code.user,
@ -514,24 +525,19 @@ class TokenView(View):
id_token.at_hash = access_token.at_hash id_token.at_hash = access_token.at_hash
refresh_token.id_token = id_token refresh_token.id_token = id_token
refresh_token.save() refresh_token.save()
response["refresh_token"] = refresh_token.token
# Delete old code # Delete old code
self.params.authorization_code.delete() self.params.authorization_code.delete()
return { return response
"access_token": access_token.token,
"refresh_token": refresh_token.token,
"token_type": TOKEN_TYPE,
"expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds()
),
"id_token": id_token.to_jwt(self.provider),
}
def create_refresh_response(self) -> dict[str, Any]: def create_refresh_response(self) -> dict[str, Any]:
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-6""" """See https://datatracker.ietf.org/doc/html/rfc6749#section-6"""
unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope)
if unauthorized_scopes: if unauthorized_scopes:
raise TokenError("invalid_scope") raise TokenError("invalid_scope")
if SCOPE_OFFLINE_ACCESS not in self.params.scope:
raise TokenError("invalid_scope")
now = timezone.now() now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
access_token = AccessToken( access_token = AccessToken(
@ -630,6 +636,16 @@ class TokenView(View):
) )
access_token.save() access_token.save()
response = {
"access_token": access_token.token,
"token_type": TOKEN_TYPE,
"expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds()
),
"id_token": access_token.id_token.to_jwt(self.provider),
}
if SCOPE_OFFLINE_ACCESS in self.params.scope:
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
refresh_token = RefreshToken( refresh_token = RefreshToken(
user=self.params.device_code.user, user=self.params.device_code.user,
@ -646,15 +662,8 @@ class TokenView(View):
id_token.at_hash = access_token.at_hash id_token.at_hash = access_token.at_hash
refresh_token.id_token = id_token refresh_token.id_token = id_token
refresh_token.save() refresh_token.save()
response["refresh_token"] = refresh_token.token
# Delete device code # Delete device code
self.params.device_code.delete() self.params.device_code.delete()
return { return response
"access_token": access_token.token,
"refresh_token": refresh_token.token,
"token_type": TOKEN_TYPE,
"expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds()
),
"id_token": id_token.to_jwt(self.provider),
}

View file

@ -8958,6 +8958,12 @@
"prompt" "prompt"
], ],
"title": "Auth mode" "title": "Auth mode"
},
"maximum_connections": {
"type": "integer",
"minimum": -2147483648,
"maximum": 2147483647,
"title": "Maximum connections"
} }
}, },
"required": [] "required": []

View file

@ -45,3 +45,14 @@ entries:
# groups is not part of the official userinfo schema, but is a quasi-standard # groups is not part of the official userinfo schema, but is a quasi-standard
"groups": [group.name for group in request.user.ak_groups.all()], "groups": [group.name for group in request.user.ak_groups.all()],
} }
- identifiers:
managed: goauthentik.io/providers/oauth2/scope-offline_access
model: authentik_providers_oauth2.scopemapping
attrs:
name: "authentik default OAuth Mapping: OpenID 'offline_access'"
scope_name: offline_access
description: "Access to request new tokens without interaction"
expression: |
# This scope grants the application a refresh token that can be used to refresh user data
# and let the application access authentik without the users interaction
return {}

View file

@ -19158,6 +19158,7 @@ paths:
- tr - tr
- tt - tt
- udm - udm
- ug
- uk - uk
- ur - ur
- uz - uz
@ -31380,6 +31381,10 @@ components:
Build actual launch URL (the provider itself does not have one, just Build actual launch URL (the provider itself does not have one, just
individual endpoints) individual endpoints)
readOnly: true readOnly: true
maximum_connections:
type: integer
maximum: 2147483647
minimum: -2147483648
required: required:
- auth_mode - auth_mode
- host - host
@ -31411,6 +31416,10 @@ components:
format: uuid format: uuid
auth_mode: auth_mode:
$ref: '#/components/schemas/AuthModeEnum' $ref: '#/components/schemas/AuthModeEnum'
maximum_connections:
type: integer
maximum: 2147483647
minimum: -2147483648
required: required:
- auth_mode - auth_mode
- host - host
@ -37297,6 +37306,10 @@ components:
format: uuid format: uuid
auth_mode: auth_mode:
$ref: '#/components/schemas/AuthModeEnum' $ref: '#/components/schemas/AuthModeEnum'
maximum_connections:
type: integer
maximum: 2147483647
minimum: -2147483648
PatchedEventMatcherPolicyRequest: PatchedEventMatcherPolicyRequest:
type: object type: object
description: Event Matcher Policy Serializer description: Event Matcher Policy Serializer
@ -42957,6 +42970,9 @@ components:
type: string type: string
model_name: model_name:
type: string type: string
requires_enterprise:
type: boolean
default: false
required: required:
- component - component
- description - description

View file

@ -74,7 +74,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
@ -82,8 +82,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
) )
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug="grafana", slug=generate_id(),
provider=provider, provider=provider,
) )
@ -129,7 +129,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
@ -137,8 +137,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
) )
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug="grafana", slug=generate_id(),
provider=provider, provider=provider,
) )
@ -200,7 +200,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
@ -208,8 +208,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
) )
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug="grafana", slug=generate_id(),
provider=provider, provider=provider,
) )

View file

@ -14,6 +14,7 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
@ -80,7 +81,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
@ -90,12 +91,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -113,12 +119,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_consent_implied(self): def test_authorization_consent_implied(self):
"""test OpenID Provider flow (default authorization flow with implied consent)""" """test OpenID Provider flow (default authorization flow with implied consent)"""
@ -128,7 +130,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
@ -138,11 +140,16 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -174,12 +181,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_logout(self): def test_authorization_logout(self):
"""test OpenID Provider flow with logout""" """test OpenID Provider flow with logout"""
@ -189,7 +192,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
@ -199,12 +202,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -244,12 +252,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_consent_explicit(self): def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)""" """test OpenID Provider flow (default authorization flow with explicit consent)"""
@ -259,7 +263,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
@ -269,12 +273,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -323,12 +332,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_denied(self): def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)""" """test OpenID Provider flow (default authorization with access deny)"""
@ -338,7 +343,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
@ -348,12 +353,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )

View file

@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
@ -29,7 +30,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
def setUp(self): def setUp(self):
self.client_id = generate_id() self.client_id = generate_id()
self.client_secret = generate_key() self.client_secret = generate_key()
self.application_slug = "test" self.application_slug = generate_id()
super().setUp() super().setUp()
def setup_client(self) -> Container: def setup_client(self) -> Container:
@ -37,7 +38,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
sleep(1) sleep(1)
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="ghcr.io/beryju/oidc-test-client:1.3", image="ghcr.io/beryju/oidc-test-client:2.1",
detach=True, detach=True,
ports={ ports={
"9009": "9009", "9009": "9009",
@ -56,9 +57,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_redirect_uri_error(self): def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)""" """test OpenID Provider flow (invalid redirect URI, check error message)"""
@ -78,10 +77,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save()
Application.objects.create( Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,
@ -101,13 +104,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied(self): def test_authorization_consent_implied(self):
"""test OpenID Provider flow (default authorization flow with implied consent)""" """test OpenID Provider flow (default authorization flow with implied consent)
(due to offline_access a consent will still be triggered)"""
sleep(1) sleep(1)
# Bootstrap all needed objects # Bootstrap all needed objects
authorization_flow = Flow.objects.get( authorization_flow = Flow.objects.get(
@ -124,11 +126,15 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() app = Application.objects.create(
Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,
provider=provider, provider=provider,
@ -137,6 +143,20 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
self.driver.get("http://localhost:9009") self.driver.get("http://localhost:9009")
self.login() self.login()
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor")))
flow_executor = self.get_shadow_root("ak-flow-executor")
consent_stage = self.get_shadow_root("ak-stage-consent", flow_executor)
self.assertIn(
app.name,
consent_stage.find_element(By.CSS_SELECTOR, "#header-text").text,
)
consent_stage.find_element(
By.CSS_SELECTOR,
"[type=submit]",
).click()
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre")))
self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{"))
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
@ -155,11 +175,9 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_explicit(self): def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)""" """test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1) sleep(1)
@ -178,10 +196,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save()
app = Application.objects.create( app = Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,
@ -224,9 +246,8 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_denied(self): def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)""" """test OpenID Provider flow (default authorization with access deny)"""
@ -246,10 +267,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save()
app = Application.objects.create( app = Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,

View file

@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
@ -37,7 +38,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
sleep(1) sleep(1)
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="ghcr.io/beryju/oidc-test-client:1.3", image="ghcr.io/beryju/oidc-test-client:2.1",
detach=True, detach=True,
ports={ ports={
"9009": "9009", "9009": "9009",
@ -56,9 +57,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_redirect_uri_error(self): def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)""" """test OpenID Provider flow (invalid redirect URI, check error message)"""
@ -78,7 +78,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
@ -101,11 +106,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied(self): def test_authorization_consent_implied(self):
"""test OpenID Provider flow (default authorization flow with implied consent)""" """test OpenID Provider flow (default authorization flow with implied consent)"""
sleep(1) sleep(1)
@ -124,7 +127,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
@ -150,11 +158,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_explicit(self): def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)""" """test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1) sleep(1)
@ -173,7 +179,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()
@ -215,9 +226,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_denied(self): def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)""" """test OpenID Provider flow (default authorization with access deny)"""
@ -237,7 +247,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
) )
) )
provider.save() provider.save()

8
web/package-lock.json generated
View file

@ -17,7 +17,7 @@
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.5.3", "@formatjs/intl-listformat": "^7.5.3",
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@goauthentik/api": "^2023.10.5-1703968412", "@goauthentik/api": "^2023.10.5-1704382057",
"@lit-labs/context": "^0.4.0", "@lit-labs/context": "^0.4.0",
"@lit-labs/task": "^3.1.0", "@lit-labs/task": "^3.1.0",
"@lit/localize": "^0.11.4", "@lit/localize": "^0.11.4",
@ -2914,9 +2914,9 @@
} }
}, },
"node_modules/@goauthentik/api": { "node_modules/@goauthentik/api": {
"version": "2023.10.5-1703968412", "version": "2023.10.5-1704382057",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.10.5-1703968412.tgz", "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.10.5-1704382057.tgz",
"integrity": "sha512-/2QDgGkWGXOYDqH49/2hNs+U8TqdE94hkMrJc8A6L+NAy8x/zKAY39eUHs85jmwt013N5duD/jKiJsRftHsDig==" "integrity": "sha512-nzmAQgTrFXiOwKDeHhq1gAfIMRilAcDPmNvjhqoQc3GQfWs5GG2lAGzIewWyxsfxNABsg+I0BYTPxN7ffD6tXw=="
}, },
"node_modules/@hcaptcha/types": { "node_modules/@hcaptcha/types": {
"version": "1.0.3", "version": "1.0.3",

View file

@ -42,7 +42,7 @@
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.5.3", "@formatjs/intl-listformat": "^7.5.3",
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@goauthentik/api": "^2023.10.5-1703968412", "@goauthentik/api": "^2023.10.5-1704382057",
"@lit-labs/context": "^0.4.0", "@lit-labs/context": "^0.4.0",
"@lit-labs/task": "^3.1.0", "@lit-labs/task": "^3.1.0",
"@lit/localize": "^0.11.4", "@lit/localize": "^0.11.4",

View file

@ -5,6 +5,7 @@ import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/forms/SearchSelect";
@ -238,6 +239,8 @@ export class OutpostForm extends ModelForm<Outpost, string> {
selected-label="${msg("Selected Applications")}" selected-label="${msg("Selected Applications")}"
></ak-dual-select-provider> ></ak-dual-select-provider>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-group aria-label="Advanced settings">
<span slot="header"> ${msg("Advanced settings")} </span>
<ak-form-element-horizontal label=${msg("Configuration")} name="config"> <ak-form-element-horizontal label=${msg("Configuration")} name="config">
<ak-codemirror <ak-codemirror
mode=${CodeMirrorMode.YAML} mode=${CodeMirrorMode.YAML}
@ -256,6 +259,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
>${msg("Documentation")}</a >${msg("Documentation")}</a
> >
</p> </p>
</ak-form-element-horizontal>`; </ak-form-element-horizontal>
</ak-form-group>`;
} }
} }

View file

@ -13,21 +13,24 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html, nothing } from "lit";
import { property } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { PropertymappingsApi, TypeCreate } from "@goauthentik/api"; import { EnterpriseApi, LicenseSummary, PropertymappingsApi, TypeCreate } from "@goauthentik/api";
@customElement("ak-property-mapping-wizard-initial") @customElement("ak-property-mapping-wizard-initial")
export class InitialPropertyMappingWizardPage extends WizardPage { export class InitialPropertyMappingWizardPage extends WizardPage {
@property({ attribute: false }) @property({ attribute: false })
mappingTypes: TypeCreate[] = []; mappingTypes: TypeCreate[] = [];
@property({ attribute: false })
enterprise?: LicenseSummary;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFForm, PFButton, PFRadio]; return [PFBase, PFForm, PFButton, PFRadio];
} }
@ -60,11 +63,20 @@ export class InitialPropertyMappingWizardPage extends WizardPage {
]; ];
this.host.isValid = true; this.host.isValid = true;
}} }}
?disabled=${type.requiresEnterprise ? !this.enterprise?.hasLicense : false}
/> />
<label class="pf-c-radio__label" for=${`${type.component}-${type.modelName}`} <label class="pf-c-radio__label" for=${`${type.component}-${type.modelName}`}
>${type.name}</label >${type.name}</label
> >
<span class="pf-c-radio__description">${type.description}</span> <span class="pf-c-radio__description">${type.description}</span>
${type.requiresEnterprise && !this.enterprise?.hasLicense
? html`
<ak-alert class="pf-c-radio__description" ?inline=${true}>
${msg("Provider require enterprise.")}
<a href="#/enterprise/licenses">${msg("Learn more")}</a>
</ak-alert>
`
: nothing}
</div>`; </div>`;
})} })}
</form>`; </form>`;
@ -80,10 +92,16 @@ export class PropertyMappingWizard extends AKElement {
@property({ attribute: false }) @property({ attribute: false })
mappingTypes: TypeCreate[] = []; mappingTypes: TypeCreate[] = [];
firstUpdated(): void { @state()
new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList().then((types) => { enterprise?: LicenseSummary;
this.mappingTypes = types;
}); async firstUpdated(): Promise<void> {
this.mappingTypes = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsAllTypesList();
this.enterprise = await new EnterpriseApi(
DEFAULT_CONFIG,
).enterpriseLicenseSummaryRetrieve();
} }
render(): TemplateResult { render(): TemplateResult {

View file

@ -4,6 +4,7 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderImportForm"; import "@goauthentik/admin/providers/saml/SAMLProviderImportForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/Alert";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/ProxyForm"; import "@goauthentik/elements/forms/ProxyForm";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
@ -13,8 +14,8 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html, nothing } from "lit";
import { property } from "lit/decorators.js"; import { property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css";
@ -22,13 +23,16 @@ import PFHint from "@patternfly/patternfly/components/Hint/hint.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ProvidersApi, TypeCreate } from "@goauthentik/api"; import { EnterpriseApi, LicenseSummary, ProvidersApi, TypeCreate } from "@goauthentik/api";
@customElement("ak-provider-wizard-initial") @customElement("ak-provider-wizard-initial")
export class InitialProviderWizardPage extends WizardPage { export class InitialProviderWizardPage extends WizardPage {
@property({ attribute: false }) @property({ attribute: false })
providerTypes: TypeCreate[] = []; providerTypes: TypeCreate[] = [];
@property({ attribute: false })
enterprise?: LicenseSummary;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFForm, PFHint, PFButton, PFRadio]; return [PFBase, PFForm, PFHint, PFButton, PFRadio];
} }
@ -79,9 +83,18 @@ export class InitialProviderWizardPage extends WizardPage {
this.host.steps = ["initial", `type-${type.component}`]; this.host.steps = ["initial", `type-${type.component}`];
this.host.isValid = true; this.host.isValid = true;
}} }}
?disabled=${type.requiresEnterprise ? !this.enterprise?.hasLicense : false}
/> />
<label class="pf-c-radio__label" for=${type.component}>${type.name}</label> <label class="pf-c-radio__label" for=${type.component}>${type.name}</label>
<span class="pf-c-radio__description">${type.description}</span> <span class="pf-c-radio__description">${type.description}</span>
${type.requiresEnterprise && !this.enterprise?.hasLicense
? html`
<ak-alert class="pf-c-radio__description" ?inline=${true}>
${msg("Provider require enterprise.")}
<a href="#/enterprise/licenses">${msg("Learn more")}</a>
</ak-alert>
`
: nothing}
</div>`; </div>`;
})} })}
</form>`; </form>`;
@ -100,15 +113,19 @@ export class ProviderWizard extends AKElement {
@property({ attribute: false }) @property({ attribute: false })
providerTypes: TypeCreate[] = []; providerTypes: TypeCreate[] = [];
@state()
enterprise?: LicenseSummary;
@property({ attribute: false }) @property({ attribute: false })
finalHandler: () => Promise<void> = () => { finalHandler: () => Promise<void> = () => {
return Promise.resolve(); return Promise.resolve();
}; };
firstUpdated(): void { async firstUpdated(): Promise<void> {
new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => { this.providerTypes = await new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList();
this.providerTypes = types; this.enterprise = await new EnterpriseApi(
}); DEFAULT_CONFIG,
).enterpriseLicenseSummaryRetrieve();
} }
render(): TemplateResult { render(): TemplateResult {
@ -121,7 +138,11 @@ export class ProviderWizard extends AKElement {
return this.finalHandler(); return this.finalHandler();
}} }}
> >
<ak-provider-wizard-initial slot="initial" .providerTypes=${this.providerTypes}> <ak-provider-wizard-initial
slot="initial"
.providerTypes=${this.providerTypes}
.enterprise=${this.enterprise}
>
</ak-provider-wizard-initial> </ak-provider-wizard-initial>
${this.providerTypes.map((type) => { ${this.providerTypes.map((type) => {
return html` return html`

View file

@ -290,9 +290,13 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
let selected = false; let selected = false;
if (!provider?.propertyMappings) { if (!provider?.propertyMappings) {
selected = selected =
scope.managed?.startsWith( // By default select all managed scope mappings, except offline_access
(scope.managed?.startsWith(
"goauthentik.io/providers/oauth2/scope-", "goauthentik.io/providers/oauth2/scope-",
) || false; ) &&
scope.managed !==
"goauthentik.io/providers/oauth2/scope-offline_access") ||
false;
} else { } else {
selected = Array.from(provider?.propertyMappings).some((su) => { selected = Array.from(provider?.propertyMappings).some((su) => {
return su == scope.pk; return su == scope.pk;

View file

@ -106,6 +106,23 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
/> />
<p class="pf-c-form__helper-text">${msg("Hostname/IP to connect to.")}</p> <p class="pf-c-form__helper-text">${msg("Hostname/IP to connect to.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Maximum concurrent connections")}
name="maximumConnections"
?required=${true}
>
<input
type="number"
value="${first(this.instance?.maximumConnections, 1)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Maximum concurrent allowed connections to this endpoint. Can be set to -1 to disable the limit.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Property mappings")} label=${msg("Property mappings")}
?required=${true} ?required=${true}

View file

@ -21,10 +21,8 @@ export class EnterpriseStatusBanner extends AKElement {
return [PFBanner]; return [PFBanner];
} }
firstUpdated(): void { async firstUpdated(): Promise<void> {
new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((b) => { this.summary = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve();
this.summary = b;
});
} }
renderBanner(): TemplateResult { renderBanner(): TemplateResult {

View file

@ -6263,6 +6263,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -6539,6 +6539,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -6179,6 +6179,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -8226,6 +8226,11 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -6387,6 +6387,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -8173,4 +8173,10 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
</trans-unit> </trans-unit>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit>
</body></file></xliff> </body></file></xliff>

View file

@ -6172,6 +6172,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -8228,6 +8228,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -6220,6 +6220,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -8108,6 +8108,11 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s56b3d54118c34b2f"> <trans-unit id="s56b3d54118c34b2f">
<source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source> <source><x id="0" equiv-text="${selectedCount}"/> item(s) marked to remove.</source>
<trans-unit id="scc7f34824150bfb8">
<source>Provider require enterprise.</source>
</trans-unit>
<trans-unit id="s31f1afc1bfe1cb3a">
<source>Learn more</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View file

@ -65,4 +65,6 @@ This designates a flow for general setup. This designation doesn't have any cons
Flows can be imported and exported to share with other people, the community and for troubleshooting. Flows can be imported to apply new functionality and apply existing workflows. Flows can be imported and exported to share with other people, the community and for troubleshooting. Flows can be imported to apply new functionality and apply existing workflows.
Download our [Example flows](./examples/flows.md) and then import them into your authentik instance.
Starting with authentik 2022.8, flows will be exported as YAML, but JSON-based flows can still be imported. Starting with authentik 2022.8, flows will be exported as YAML, but JSON-based flows can still be imported.

View file

@ -35,12 +35,20 @@ To access the user's email address, a scope of `user:email` is required. To acce
### `authorization_code`: ### `authorization_code`:
This grant is used to convert an authorization code to a refresh token. The authorization code is retrieved through the Authorization flow, and can only be used once, and expires quickly. This grant is used to convert an authorization code to an access token (and optionally refresh token). The authorization code is retrieved through the Authorization flow, and can only be used once, and expires quickly.
:::info
Starting with authentik 2024.1, applications only receive an access token. To receive a refresh token, applications must be allowed to request the `offline_access` scope in authentik and also be configured to request the scope.
:::
### `refresh_token`: ### `refresh_token`:
Refresh tokens can be used as long-lived tokens to access user data, and further renew the refresh token down the road. Refresh tokens can be used as long-lived tokens to access user data, and further renew the refresh token down the road.
:::info
Starting with authentik 2024.1, this grant requires the `offline_access` scope.
:::
### `client_credentials`: ### `client_credentials`:
See [Machine-to-machine authentication](./client_credentials) See [Machine-to-machine authentication](./client_credentials)

View file

@ -17,6 +17,12 @@ slug: "/releases/2024.1"
- `authentik_outpost_radius_requests_rejected` -> `authentik_outpost_radius_requests_rejected_total` - `authentik_outpost_radius_requests_rejected` -> `authentik_outpost_radius_requests_rejected_total`
- `authentik_main_requests` -> `authentik_main_request_duration_seconds` - `authentik_main_requests` -> `authentik_main_request_duration_seconds`
- Required `offline_access` scope for Refresh tokens
The OAuth2 provider ships with a new default scope called `offline_access`, which must be requested by applications that need a refresh token. Previously, authentik would always issue a refresh token for the _Authorization code_ and _Device code_ OAuth grants.
Applications which require will need their configuration update to include the `offline_access` scope mapping.
## New features ## New features
- "Pretend user exists" option for Identification stage - "Pretend user exists" option for Identification stage

View file

@ -15,7 +15,7 @@ Jellyfin does not have any native external authentication support as of the writ
::: :::
:::note :::note
Currently there are two plugins for Jelyfin that provide external authenticaion, an OIDC plugin and an LDAP plugin. This guide focuses on the use of the LDAP plugin. Currently, there are two plugins for Jellyfin that provide external authentication, an OIDC plugin and an LDAP plugin. This guide focuses on the use of the LDAP plugin.
::: :::
:::caution :::caution
@ -34,49 +34,49 @@ The following placeholders will be used:
## Jellyfin configuration ## Jellyfin configuration
1. If you don't have one already create an LDAP bind user before starting these steps. 1. If you don't have one already, create an LDAP bind user before starting these steps.
- Ideally, this user doesn't have any permissions other than the ability to view other users. However, some functions do require an account with permissions. - Ideally, this user doesn't have any permissions other than the ability to view other users. However, some functions do require an account with permissions.
- This user must be part of the group that is specified in the "Search group" in the LDAP outpost. - This user must be part of the group that is specified in the "Search group" in the LDAP outpost.
2. Navigate to your Jellyfin installation and log in with the admin account or currently configured local admin. 2. Navigate to your Jellyfin installation and log in with the admin account or currently configured local admin.
3. Open the administrator dashboard and go to the "Plugins" section. 3. Open the administrator dashboard and go to the "Plugins" section.
4. Click "Catalog" at the top of the page, and locate the "LDAP Authentication Plugin" 4. Click "Catalog" at the top of the page, and locate the "LDAP Authentication Plugin"
5. Install the plugin. You may need to restart Jellyfin to finish installation. 5. Install the plugin. You may need to restart Jellyfin to finish installation.
6. Once finished navigate back to the plugins section of the admin dashboard, click the 3 dots on the "LDAP-Auth Plugin" card, and click settings. 6. Once finished, navigate back to the plugins section of the admin dashboard, click the 3 dots on the "LDAP-Auth Plugin" card, and click settings.
7. Configure the LDAP Settings as follows: 7. Configure the LDAP Settings as follows:
- `LDAP Server`: `ldap.company.com` - `LDAP Server`: `ldap.company.com`
- `LDAP Port`: 636 - `LDAP Port`: 636
- `Secure LDAP`: **Checked** - `Secure LDAP`: **Checked**
- `StartTLS`: Unchecked - `StartTLS`: Unchecked
- `Skip SSL/TLS Verification`: - `Skip SSL/TLS Verification`:
- If using a certificate issued by a certificate authority Jellyfin trusts, leave this unchecked. - If using a certificate issued by a certificate authority, Jellyfin trusts, leave this unchecked.
- If you're using a self signed certificate, check this box. - If you're using a self-signed certificate, check this box.
- `Allow password change`: Unchecked - `Allow password change`: Unchecked
- Since authentik already has a frontend for password resets, its not necessary to include this in Jellyfin, especially since it requires bind user to have privileges. - Since authentik already has a frontend for password resets, it's not necessary to include this in Jellyfin, especially since it requires bind user to have privileges.
- `Password Reset URL`: Empty - `Password Reset URL`: Empty
- `LDAP Bind User`: Set this to a the user you want to bind to in authentik. By default the path will be `ou=users,dc=company,dc=com` so the LDAP Bind user will be `cn=ldap_bind_user,ou=users,dc=company,dc=com`. - `LDAP Bind User`: Set this to a user you want to bind to in authentik. By default, the path will be `ou=users,dc=company,dc=com` so the LDAP Bind user will be `cn=ldap_bind_user,ou=users,dc=company,dc=com`.
- `LDAP Bind User Password`: The Password of the user. If using a Service account, this is the token. - `LDAP Bind User Password`: The Password of the user. If using a Service account, this is the token.
- `LDAP Base DN for Searches`: the base DN for LDAP queries. To query all users set this to `dc=company,dc=com`. - `LDAP Base DN for Searches`: the base DN for LDAP queries. To query all users, set this to `dc=company,dc=com`.
- You can specify an OU if you divide your users up into different OUs and only want to query a specific OU. - You can specify an OU if you divide your users up into different OUs and only want to query a specific OU.
At this point click `Save and Test LDAP Server Settings`. If the settings are correct you will see: At this point, click `Save and Test LDAP Server Settings`. If the settings are correct, you will see:
`Connect(Success); Bind(Success); Base Search (Found XY Entities)` `Connect(Success); Bind(Success); Base Search (Found XY Entities)`
- `LDAP User Filter`: This is used to a user filter on what users are allowed to login. **This must be set** - `LDAP User Filter`: This is used to a user filter on what users are allowed to login. **This must be set**
- To allow all users: `(objectClass=user)` - To allow all users: `(objectClass=user)`
- To only allow users in a specific group: `(memberOf=cn=jellyfin_users,ou=groups,dc=company,dc=com)` - To only allow users in a specific group: `(memberOf=cn=jellyfin_users,ou=groups,dc=company,dc=com)`
- Good Docs on LDAP Filters: [atlassian.com](https://confluence.atlassian.com/kb/how-to-write-ldap-search-filters-792496933.html) - Good Docs on LDAP Filters: [atlassian.com](https://confluence.atlassian.com/kb/how-to-write-ldap-search-filters-792496933.html)
- `LDAP Admin Base DN`: All of the users in this DN are automatically set as admins. - `LDAP Admin Base DN`: All the users in this DN are automatically set as admins.
- This can be left blank. Admins can be set manually outside of this filter - This can be left blank. Admins can be set manually outside this filter
- `LDAP Admin Filter`: Similar to the user filter, but every matched user is set as admin. - `LDAP Admin Filter`: Similar to the user filter, but every matched user is set as admin.
- This can be left blank. Admins can be set manually outside of this filter - This can be left blank. Admins can be set manually outside this filter
At this point click `Save and Test LDAP Filter Settings`. If the settings are correct you will see: At this point, click `Save and Test LDAP Filter Settings`. If the settings are correct, you will see:
`Found X user(s), Y admin(s)` `Found X user(s), Y admin(s)`
- `LDAP Attributes`: `uid, cn, mail, displayName` - `LDAP Attributes`: `uid, cn, mail, displayName`
- `Enable case Insensitive Username`: **Checked** - `Enable case Insensitive Username`: **Checked**
At this point, enter in a username and click "Save Search Attribute Settings and Query User". If the settings are correct you will see: At this point, enter a username and click "Save Search Attribute Settings and Query User". If the settings are correct, you will see:
`Found User: cn=test,ou=users,dc=company,dc=com` `Found User: cn=test,ou=users,dc=company,dc=com`
- `Enabled User Creation`: **Checked** - `Enabled User Creation`: **Checked**