From b93ad8615cb3bee1d281bd6bcf2898a227664046 Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 3 Jan 2024 14:47:17 +0100 Subject: [PATCH 1/8] enterprise/providers/rac: create authorize_application event when creating token (#8050) * events: don't log creation of creation token Signed-off-by: Jens Langhammer * enterprise/providers/rac: create authorize_application event when creating token Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/rac/views.py | 9 +++++++++ authentik/events/middleware.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/authentik/enterprise/providers/rac/views.py b/authentik/enterprise/providers/rac/views.py index 31a25c721..e50f6ee5b 100644 --- a/authentik/enterprise/providers/rac/views.py +++ b/authentik/enterprise/providers/rac/views.py @@ -10,6 +10,7 @@ from authentik.core.models import Application, AuthenticatedSession from authentik.core.views.interface import InterfaceView from authentik.enterprise.policy import EnterprisePolicyAccessView 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.exceptions import FlowNonApplicableException from authentik.flows.models import in_memory_stage @@ -43,6 +44,7 @@ class RACStartView(EnterprisePolicyAccessView): plan.insert_stage( in_memory_stage( RACFinalStage, + application=self.application, endpoint=self.endpoint, provider=self.provider, ) @@ -90,6 +92,7 @@ class RACFinalStage(RedirectStage): def get_challenge(self, *args, **kwargs) -> RedirectChallenge: endpoint: Endpoint = self.executor.current_stage.endpoint provider: RACProvider = self.executor.current_stage.provider + application: Application = self.executor.current_stage.application token = ConnectionToken.objects.create( provider=provider, endpoint=endpoint, @@ -100,6 +103,12 @@ class RACFinalStage(RedirectStage): expires=now() + timedelta_from_string(provider.connection_expiry), expiring=True, ) + Event.new( + EventAction.AUTHORIZE_APPLICATION, + authorized_application=application, + flow=self.executor.plan.flow_pk, + endpoint=endpoint.name, + ).from_http(self.request) setattr( self.executor.current_stage, "destination", diff --git a/authentik/events/middleware.py b/authentik/events/middleware.py index 7834bae5e..ea7e6001f 100644 --- a/authentik/events/middleware.py +++ b/authentik/events/middleware.py @@ -20,6 +20,7 @@ from authentik.core.models import ( User, UserSourceConnection, ) +from authentik.enterprise.providers.rac.models import ConnectionToken from authentik.events.models import Event, EventAction, Notification from authentik.events.utils import model_to_dict from authentik.flows.models import FlowToken, Stage @@ -54,6 +55,7 @@ IGNORED_MODELS = ( SCIMUser, SCIMGroup, Reputation, + ConnectionToken, ) From 116ac30c7275aa9e97cea8d96bf91a788146f3cc Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 4 Jan 2024 16:18:12 +0100 Subject: [PATCH 2/8] enterprise/providers/rac: add alert that enterprise is required for RAC (#8057) add alert that enterprise is required for RAC Signed-off-by: Jens Langhammer --- authentik/core/api/propertymappings.py | 2 + authentik/core/api/providers.py | 2 + authentik/core/api/utils.py | 3 +- authentik/enterprise/api.py | 14 ++++ authentik/enterprise/apps.py | 6 +- .../enterprise/providers/rac/api/endpoints.py | 3 +- .../providers/rac/api/property_mappings.py | 3 +- .../enterprise/providers/rac/api/providers.py | 3 +- authentik/enterprise/providers/rac/apps.py | 4 +- authentik/enterprise/providers/rac/models.py | 2 +- schema.yml | 4 ++ .../PropertyMappingWizard.ts | 32 +++++++-- web/src/admin/providers/ProviderWizard.ts | 37 +++++++--- .../enterprise/EnterpriseStatusBanner.ts | 6 +- web/xliff/de.xlf | 6 ++ web/xliff/en.xlf | 6 ++ web/xliff/es.xlf | 6 ++ web/xliff/fr.xlf | 72 ++++++++++--------- web/xliff/pl.xlf | 6 ++ web/xliff/pseudo-LOCALE.xlf | 6 ++ web/xliff/tr.xlf | 6 ++ web/xliff/zh-Hans.xlf | 54 +++++++------- web/xliff/zh-Hant.xlf | 6 ++ web/xliff/zh_TW.xlf | 6 ++ 24 files changed, 211 insertions(+), 84 deletions(-) diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 1e7436be9..d0fa7267b 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -19,6 +19,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.models import PropertyMapping +from authentik.enterprise.apps import EnterpriseConfig from authentik.events.utils import sanitize_item from authentik.lib.utils.reflection import all_subclasses from authentik.policies.api.exec import PolicyTestSerializer @@ -95,6 +96,7 @@ class PropertyMappingViewSet( "description": subclass.__doc__, "component": subclass().component, "model_name": subclass._meta.model_name, + "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), } ) return Response(TypeCreateSerializer(data, many=True).data) diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py index a5095dcde..6c0f4db06 100644 --- a/authentik/core/api/providers.py +++ b/authentik/core/api/providers.py @@ -16,6 +16,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.models import Provider +from authentik.enterprise.apps import EnterpriseConfig from authentik.lib.utils.reflection import all_subclasses @@ -113,6 +114,7 @@ class ProviderViewSet( "description": subclass.__doc__, "component": subclass().component, "model_name": subclass._meta.model_name, + "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), } ) data.append( diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index c7a188f5c..c79fec22e 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -5,7 +5,7 @@ from django.db.models import Model from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.plumbing import build_basic_type 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 @@ -74,6 +74,7 @@ class TypeCreateSerializer(PassiveSerializer): description = CharField(required=True) component = CharField(required=True) model_name = CharField(required=True) + requires_enterprise = BooleanField(default=False) class CacheSerializer(PassiveSerializer): diff --git a/authentik/enterprise/api.py b/authentik/enterprise/api.py index fdf0a11fc..c13e34b6d 100644 --- a/authentik/enterprise/api.py +++ b/authentik/enterprise/api.py @@ -2,9 +2,11 @@ from datetime import datetime, timedelta from django.utils.timezone import now +from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField from rest_framework.permissions import IsAuthenticated 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 +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): """License Serializer""" diff --git a/authentik/enterprise/apps.py b/authentik/enterprise/apps.py index 2d918da17..a0b9bed6d 100644 --- a/authentik/enterprise/apps.py +++ b/authentik/enterprise/apps.py @@ -2,7 +2,11 @@ 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""" name = "authentik.enterprise" diff --git a/authentik/enterprise/providers/rac/api/endpoints.py b/authentik/enterprise/providers/rac/api/endpoints.py index b0b0239c5..e1c6c5dd8 100644 --- a/authentik/enterprise/providers/rac/api/endpoints.py +++ b/authentik/enterprise/providers/rac/api/endpoints.py @@ -15,6 +15,7 @@ from structlog.stdlib import get_logger from authentik.core.api.used_by import UsedByMixin 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.models import Endpoint 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}" -class EndpointSerializer(ModelSerializer): +class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer): """Endpoint Serializer""" provider_obj = RACProviderSerializer(source="provider", read_only=True) diff --git a/authentik/enterprise/providers/rac/api/property_mappings.py b/authentik/enterprise/providers/rac/api/property_mappings.py index 35daec95c..4afef68bb 100644 --- a/authentik/enterprise/providers/rac/api/property_mappings.py +++ b/authentik/enterprise/providers/rac/api/property_mappings.py @@ -5,10 +5,11 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import JSONDictField +from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.providers.rac.models import RACPropertyMapping -class RACPropertyMappingSerializer(PropertyMappingSerializer): +class RACPropertyMappingSerializer(EnterpriseRequiredMixin, PropertyMappingSerializer): """RACPropertyMapping Serializer""" static_settings = JSONDictField() diff --git a/authentik/enterprise/providers/rac/api/providers.py b/authentik/enterprise/providers/rac/api/providers.py index 6dd4f9f82..cda6c2af3 100644 --- a/authentik/enterprise/providers/rac/api/providers.py +++ b/authentik/enterprise/providers/rac/api/providers.py @@ -4,10 +4,11 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin +from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.providers.rac.models import RACProvider -class RACProviderSerializer(ProviderSerializer): +class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): """RACProvider Serializer""" outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all") diff --git a/authentik/enterprise/providers/rac/apps.py b/authentik/enterprise/providers/rac/apps.py index 973159bb9..13930faae 100644 --- a/authentik/enterprise/providers/rac/apps.py +++ b/authentik/enterprise/providers/rac/apps.py @@ -1,8 +1,8 @@ """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""" name = "authentik.enterprise.providers.rac" diff --git a/authentik/enterprise/providers/rac/models.py b/authentik/enterprise/providers/rac/models.py index d79bbd54c..f2806f32b 100644 --- a/authentik/enterprise/providers/rac/models.py +++ b/authentik/enterprise/providers/rac/models.py @@ -35,7 +35,7 @@ class AuthenticationMode(models.TextChoices): class RACProvider(Provider): - """Remotely access computers/servers""" + """Remotely access computers/servers via RDP/SSH/VNC.""" settings = models.JSONField(default=dict) auth_mode = models.TextField( diff --git a/schema.yml b/schema.yml index 0ea6e8ee0..09b351707 100644 --- a/schema.yml +++ b/schema.yml @@ -19158,6 +19158,7 @@ paths: - tr - tt - udm + - ug - uk - ur - uz @@ -42957,6 +42958,9 @@ components: type: string model_name: type: string + requires_enterprise: + type: boolean + default: false required: - component - description diff --git a/web/src/admin/property-mappings/PropertyMappingWizard.ts b/web/src/admin/property-mappings/PropertyMappingWizard.ts index 4773dd93a..4f0ab6122 100644 --- a/web/src/admin/property-mappings/PropertyMappingWizard.ts +++ b/web/src/admin/property-mappings/PropertyMappingWizard.ts @@ -13,21 +13,24 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage"; import { msg, str } from "@lit/localize"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; -import { CSSResult, TemplateResult, html } from "lit"; -import { property } from "lit/decorators.js"; +import { CSSResult, TemplateResult, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.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") export class InitialPropertyMappingWizardPage extends WizardPage { @property({ attribute: false }) mappingTypes: TypeCreate[] = []; + @property({ attribute: false }) + enterprise?: LicenseSummary; + static get styles(): CSSResult[] { return [PFBase, PFForm, PFButton, PFRadio]; } @@ -60,11 +63,20 @@ export class InitialPropertyMappingWizardPage extends WizardPage { ]; this.host.isValid = true; }} + ?disabled=${type.requiresEnterprise ? !this.enterprise?.hasLicense : false} /> ${type.description} + ${type.requiresEnterprise && !this.enterprise?.hasLicense + ? html` + + ${msg("Provider require enterprise.")} + ${msg("Learn more")} + + ` + : nothing} `; })} `; @@ -80,10 +92,16 @@ export class PropertyMappingWizard extends AKElement { @property({ attribute: false }) mappingTypes: TypeCreate[] = []; - firstUpdated(): void { - new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList().then((types) => { - this.mappingTypes = types; - }); + @state() + enterprise?: LicenseSummary; + + async firstUpdated(): Promise { + this.mappingTypes = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsAllTypesList(); + this.enterprise = await new EnterpriseApi( + DEFAULT_CONFIG, + ).enterpriseLicenseSummaryRetrieve(); } render(): TemplateResult { diff --git a/web/src/admin/providers/ProviderWizard.ts b/web/src/admin/providers/ProviderWizard.ts index a65945354..7f19b4d02 100644 --- a/web/src/admin/providers/ProviderWizard.ts +++ b/web/src/admin/providers/ProviderWizard.ts @@ -4,6 +4,7 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderImportForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/Alert"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/forms/ProxyForm"; 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 { customElement } from "@lit/reactive-element/decorators/custom-element.js"; -import { CSSResult, TemplateResult, html } from "lit"; -import { property } from "lit/decorators.js"; +import { CSSResult, TemplateResult, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.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 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") export class InitialProviderWizardPage extends WizardPage { @property({ attribute: false }) providerTypes: TypeCreate[] = []; + @property({ attribute: false }) + enterprise?: LicenseSummary; + static get styles(): CSSResult[] { return [PFBase, PFForm, PFHint, PFButton, PFRadio]; } @@ -79,9 +83,18 @@ export class InitialProviderWizardPage extends WizardPage { this.host.steps = ["initial", `type-${type.component}`]; this.host.isValid = true; }} + ?disabled=${type.requiresEnterprise ? !this.enterprise?.hasLicense : false} /> ${type.description} + ${type.requiresEnterprise && !this.enterprise?.hasLicense + ? html` + + ${msg("Provider require enterprise.")} + ${msg("Learn more")} + + ` + : nothing} `; })} `; @@ -100,15 +113,19 @@ export class ProviderWizard extends AKElement { @property({ attribute: false }) providerTypes: TypeCreate[] = []; + @state() + enterprise?: LicenseSummary; + @property({ attribute: false }) finalHandler: () => Promise = () => { return Promise.resolve(); }; - firstUpdated(): void { - new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => { - this.providerTypes = types; - }); + async firstUpdated(): Promise { + this.providerTypes = await new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(); + this.enterprise = await new EnterpriseApi( + DEFAULT_CONFIG, + ).enterpriseLicenseSummaryRetrieve(); } render(): TemplateResult { @@ -121,7 +138,11 @@ export class ProviderWizard extends AKElement { return this.finalHandler(); }} > - + ${this.providerTypes.map((type) => { return html` diff --git a/web/src/elements/enterprise/EnterpriseStatusBanner.ts b/web/src/elements/enterprise/EnterpriseStatusBanner.ts index 0ac115457..09d376759 100644 --- a/web/src/elements/enterprise/EnterpriseStatusBanner.ts +++ b/web/src/elements/enterprise/EnterpriseStatusBanner.ts @@ -21,10 +21,8 @@ export class EnterpriseStatusBanner extends AKElement { return [PFBanner]; } - firstUpdated(): void { - new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((b) => { - this.summary = b; - }); + async firstUpdated(): Promise { + this.summary = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve(); } renderBanner(): TemplateResult { diff --git a/web/xliff/de.xlf b/web/xliff/de.xlf index 8cac9d9de..403d08256 100644 --- a/web/xliff/de.xlf +++ b/web/xliff/de.xlf @@ -6237,6 +6237,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index aa28b7c6a..a8c3a759b 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6513,6 +6513,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index cbe16ba84..ece851727 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -6153,6 +6153,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more diff --git a/web/xliff/fr.xlf b/web/xliff/fr.xlf index 407618e65..3bac1b49b 100644 --- a/web/xliff/fr.xlf +++ b/web/xliff/fr.xlf @@ -1,4 +1,4 @@ - + @@ -613,9 +613,9 @@ Il y a jour(s) - The URL "" was not found. - L'URL " - " n'a pas été trouvée. + The URL "" was not found. + L'URL " + " n'a pas été trouvée. @@ -1057,8 +1057,8 @@ Il y a jour(s) - To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. - Pour permettre n'importe quelle URI de redirection, définissez cette valeur sur ".*". Soyez conscient des possibles implications de sécurité que cela peut avoir. + To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. + Pour permettre n'importe quelle URI de redirection, définissez cette valeur sur ".*". Soyez conscient des possibles implications de sécurité que cela peut avoir. @@ -1630,7 +1630,7 @@ Il y a jour(s) Token to authenticate with. Currently only bearer authentication is supported. - Jeton d'authentification à utiliser. Actuellement, seule l'authentification "bearer authentication" est prise en charge. + Jeton d'authentification à utiliser. Actuellement, seule l'authentification "bearer authentication" est prise en charge. @@ -1798,8 +1798,8 @@ Il y a jour(s) - Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". - Entrez une URL complète, un chemin relatif ou utilisez 'fa://fa-test' pour utiliser l'icône Font Awesome "fa-test". + Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". + Entrez une URL complète, un chemin relatif ou utilisez 'fa://fa-test' pour utiliser l'icône Font Awesome "fa-test". @@ -2892,7 +2892,7 @@ doesn't pass when either or both of the selected options are equal or above the To use SSL instead, use 'ldaps://' and disable this option. - Pour utiliser SSL à la base, utilisez "ldaps://" et désactviez cette option. + Pour utiliser SSL à la base, utilisez "ldaps://" et désactviez cette option. @@ -2981,8 +2981,8 @@ doesn't pass when either or both of the selected options are equal or above the - Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' - Champ qui contient les membres d'un groupe. Si vous utilisez le champ "memberUid", la valeur est censée contenir un nom distinctif relatif, par exemple 'memberUid=un-utilisateur' au lieu de 'memberUid=cn=un-utilisateur,ou=groups,...' + Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' + Champ qui contient les membres d'un groupe. Si vous utilisez le champ "memberUid", la valeur est censée contenir un nom distinctif relatif, par exemple 'memberUid=un-utilisateur' au lieu de 'memberUid=cn=un-utilisateur,ou=groups,...' @@ -3277,7 +3277,7 @@ doesn't pass when either or both of the selected options are equal or above the Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. - Moment où les utilisateurs temporaires doivent être supprimés. Cela ne s'applique que si votre IDP utilise le format NameID "transient" et que l'utilisateur ne se déconnecte pas manuellement. + Moment où les utilisateurs temporaires doivent être supprimés. Cela ne s'applique que si votre IDP utilise le format NameID "transient" et que l'utilisateur ne se déconnecte pas manuellement. @@ -3445,7 +3445,7 @@ doesn't pass when either or both of the selected options are equal or above the Optionally set the 'FriendlyName' value of the Assertion attribute. - Indiquer la valeur "FriendlyName" de l'attribut d'assertion (optionnel) + Indiquer la valeur "FriendlyName" de l'attribut d'assertion (optionnel) @@ -3774,8 +3774,8 @@ doesn't pass when either or both of the selected options are equal or above the - When using an external logging solution for archiving, this can be set to "minutes=5". - En cas d'utilisation d'une solution de journalisation externe pour l'archivage, cette valeur peut être fixée à "minutes=5". + When using an external logging solution for archiving, this can be set to "minutes=5". + En cas d'utilisation d'une solution de journalisation externe pour l'archivage, cette valeur peut être fixée à "minutes=5". @@ -3784,8 +3784,8 @@ doesn't pass when either or both of the selected options are equal or above the - Format: "weeks=3;days=2;hours=3,seconds=2". - Format : "weeks=3;days=2;hours=3,seconds=2". + Format: "weeks=3;days=2;hours=3,seconds=2". + Format : "weeks=3;days=2;hours=3,seconds=2". @@ -3981,10 +3981,10 @@ doesn't pass when either or both of the selected options are equal or above the - Are you sure you want to update ""? + Are you sure you want to update ""? Êtes-vous sûr de vouloir mettre à jour - " - " ? + " + " ? @@ -5070,8 +5070,8 @@ doesn't pass when either or both of the selected options are equal or above the - A "roaming" authenticator, like a YubiKey - Un authentificateur "itinérant", comme une YubiKey + A "roaming" authenticator, like a YubiKey + Un authentificateur "itinérant", comme une YubiKey @@ -5396,7 +5396,7 @@ doesn't pass when either or both of the selected options are equal or above the Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable. - Afficher des champs de saisie arbitraires à l'utilisateur, par exemple pendant l'inscription. Les données sont enregistrées dans le contexte du flux sous la variable "prompt_data". + Afficher des champs de saisie arbitraires à l'utilisateur, par exemple pendant l'inscription. Les données sont enregistrées dans le contexte du flux sous la variable "prompt_data". @@ -5405,10 +5405,10 @@ doesn't pass when either or both of the selected options are equal or above the - ("", of type ) + ("", of type ) - (" - ", de type + (" + ", de type ) @@ -5457,8 +5457,8 @@ doesn't pass when either or both of the selected options are equal or above the - If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. - Si défini à une durée supérieure à 0, l'utilisateur aura la possibilité de choisir de "rester connecté", ce qui prolongera sa session jusqu'à la durée spécifiée ici. + If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. + Si défini à une durée supérieure à 0, l'utilisateur aura la possibilité de choisir de "rester connecté", ce qui prolongera sa session jusqu'à la durée spécifiée ici. @@ -6242,7 +6242,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system. - Peut être au format "unix://" pour une connexion à un service docker local, "ssh://" pour une connexion via SSH, ou "https://:2376" pour une connexion à un système distant. + Peut être au format "unix://" pour une connexion à un service docker local, "ssh://" pour une connexion via SSH, ou "https://:2376" pour une connexion à un système distant. @@ -7549,7 +7549,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Use this provider with nginx's auth_request or traefik's forwardAuth. Each application/domain needs its own provider. Additionally, on each domain, /outpost.goauthentik.io must be routed to the outpost (when using a managed outpost, this is done for you). - Utilisez ce fournisseur avec l'option "auth_request" de Nginx ou "forwardAuth" de Traefik. Chaque application/domaine a besoin de son propre fournisseur. De plus, sur chaque domaine, "/outpost.goauthentik.io" doit être routé vers le poste avancé (lorsque vous utilisez un poste avancé géré, cela est fait pour vous). + Utilisez ce fournisseur avec l'option "auth_request" de Nginx ou "forwardAuth" de Traefik. Chaque application/domaine a besoin de son propre fournisseur. De plus, sur chaque domaine, "/outpost.goauthentik.io" doit être routé vers le poste avancé (lorsque vous utilisez un poste avancé géré, cela est fait pour vous). Default relay state @@ -7963,7 +7963,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Utilisateur créé et ajouté au groupe avec succès - This user will be added to the group "". + This user will be added to the group "". Cet utilisateur sera ajouté au groupe &quot;&quot;. @@ -8201,7 +8201,13 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Determines how long a session lasts before being disconnected and requiring re-authorization. Détermine combien de temps une session dure avant déconnexion et ré-authorisation. + + + Provider require enterprise. + + + Learn more - \ No newline at end of file + diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index b52ea863c..a0e16c3be 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -6361,6 +6361,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index bc883faa7..217082220 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -8099,4 +8099,10 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + Provider require enterprise. + + + Learn more + diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index 7b03127c0..f2cd5d161 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -6146,6 +6146,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index 1ef89db75..d1572b445 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -613,9 +613,9 @@ - The URL "" was not found. - 未找到 URL " - "。 + The URL "" was not found. + 未找到 URL " + "。 @@ -1057,8 +1057,8 @@ - To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. - 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 + To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. + 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 @@ -1799,8 +1799,8 @@ - Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". - 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 + Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". + 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 @@ -2983,8 +2983,8 @@ doesn't pass when either or both of the selected options are equal or above the - Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' - 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' + Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' + 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' @@ -3776,8 +3776,8 @@ doesn't pass when either or both of the selected options are equal or above the - When using an external logging solution for archiving, this can be set to "minutes=5". - 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 + When using an external logging solution for archiving, this can be set to "minutes=5". + 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 @@ -3786,8 +3786,8 @@ doesn't pass when either or both of the selected options are equal or above the - Format: "weeks=3;days=2;hours=3,seconds=2". - 格式:"weeks=3;days=2;hours=3,seconds=2"。 + Format: "weeks=3;days=2;hours=3,seconds=2". + 格式:"weeks=3;days=2;hours=3,seconds=2"。 @@ -3983,10 +3983,10 @@ doesn't pass when either or both of the selected options are equal or above the - Are you sure you want to update ""? + Are you sure you want to update ""? 您确定要更新 - " - " 吗? + " + " 吗? @@ -5072,7 +5072,7 @@ doesn't pass when either or both of the selected options are equal or above the - A "roaming" authenticator, like a YubiKey + A "roaming" authenticator, like a YubiKey 像 YubiKey 这样的“漫游”身份验证器 @@ -5407,10 +5407,10 @@ doesn't pass when either or both of the selected options are equal or above the - ("", of type ) + ("", of type ) - (" - ",类型为 + (" + ",类型为 @@ -5459,7 +5459,7 @@ doesn't pass when either or both of the selected options are equal or above the - If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. + If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. 如果设置时长大于 0,用户可以选择“保持登录”选项,这将使用户的会话延长此处设置的时间。 @@ -7965,7 +7965,7 @@ Bindings to groups/users are checked against the user of the event. 成功创建用户并添加到组 - This user will be added to the group "". + This user will be added to the group "". 此用户将会被添加到组 &quot;&quot;。 @@ -8203,7 +8203,13 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. 设置会话在被断开连接并需要重新授权之前持续的时间。 + + + Provider require enterprise. + + + Learn more - \ No newline at end of file + diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index 65794b706..35b356efa 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -6194,6 +6194,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index 9a9b690bd..8ec2bf521 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -8083,6 +8083,12 @@ Bindings to groups/users are checked against the user of the event. Determines how long a session lasts before being disconnected and requiring re-authorization. + + + Provider require enterprise. + + + Learn more From 519062bc39d80e0d2d1f73446d2804ac52feb510 Mon Sep 17 00:00:00 2001 From: "authentik-automation[bot]" <135050075+authentik-automation[bot]@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:26:58 +0100 Subject: [PATCH 3/8] web: bump API Client version (#8058) Signed-off-by: GitHub Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com> --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index d6305b661..945ad5ee2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@formatjs/intl-listformat": "^7.5.3", "@fortawesome/fontawesome-free": "^6.5.1", - "@goauthentik/api": "^2023.10.5-1703968412", + "@goauthentik/api": "^2023.10.5-1704381512", "@lit-labs/context": "^0.4.0", "@lit-labs/task": "^3.1.0", "@lit/localize": "^0.11.4", @@ -2913,9 +2913,9 @@ } }, "node_modules/@goauthentik/api": { - "version": "2023.10.5-1703968412", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.10.5-1703968412.tgz", - "integrity": "sha512-/2QDgGkWGXOYDqH49/2hNs+U8TqdE94hkMrJc8A6L+NAy8x/zKAY39eUHs85jmwt013N5duD/jKiJsRftHsDig==" + "version": "2023.10.5-1704381512", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.10.5-1704381512.tgz", + "integrity": "sha512-kB7OZCwNEKvlVzzEkiA3bx9qIJ4OA44qmQZQ03iI05DKIJWqU+CssDyHeMUGv5Vdma04xdf+3+BOzrc+ZJWX+A==" }, "node_modules/@hcaptcha/types": { "version": "1.0.3", diff --git a/web/package.json b/web/package.json index 92cb66ec9..78a4f32ef 100644 --- a/web/package.json +++ b/web/package.json @@ -42,7 +42,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@formatjs/intl-listformat": "^7.5.3", "@fortawesome/fontawesome-free": "^6.5.1", - "@goauthentik/api": "^2023.10.5-1703968412", + "@goauthentik/api": "^2023.10.5-1704381512", "@lit-labs/context": "^0.4.0", "@lit-labs/task": "^3.1.0", "@lit/localize": "^0.11.4", From 20643954348fbda587b235c74d7bdbaa2bf25e28 Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 4 Jan 2024 16:27:16 +0100 Subject: [PATCH 4/8] enterprise/providers/rac: add option to limit concurrent connections to endpoint (#8053) * enterprise/providers/rac: add option to limit concurrent connections to endpoint Signed-off-by: Jens Langhammer * unrelated: put outpost settings in group Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .../enterprise/providers/rac/api/endpoints.py | 1 + .../0002_endpoint_maximum_connections.py | 17 ++++++++ authentik/enterprise/providers/rac/models.py | 1 + .../providers/rac/tests/test_endpoints_api.py | 3 ++ authentik/enterprise/providers/rac/views.py | 36 +++++++++++----- blueprints/schema.json | 6 +++ schema.yml | 12 ++++++ web/src/admin/outposts/OutpostForm.ts | 42 ++++++++++--------- web/src/admin/providers/rac/EndpointForm.ts | 17 ++++++++ 9 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py diff --git a/authentik/enterprise/providers/rac/api/endpoints.py b/authentik/enterprise/providers/rac/api/endpoints.py index e1c6c5dd8..1af281ef8 100644 --- a/authentik/enterprise/providers/rac/api/endpoints.py +++ b/authentik/enterprise/providers/rac/api/endpoints.py @@ -60,6 +60,7 @@ class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer): "property_mappings", "auth_mode", "launch_url", + "maximum_connections", ] diff --git a/authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py b/authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py new file mode 100644 index 000000000..0760f2313 --- /dev/null +++ b/authentik/enterprise/providers/rac/migrations/0002_endpoint_maximum_connections.py @@ -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), + ), + ] diff --git a/authentik/enterprise/providers/rac/models.py b/authentik/enterprise/providers/rac/models.py index f2806f32b..927bd23fe 100644 --- a/authentik/enterprise/providers/rac/models.py +++ b/authentik/enterprise/providers/rac/models.py @@ -81,6 +81,7 @@ class Endpoint(SerializerModel, PolicyBindingModel): settings = models.JSONField(default=dict) auth_mode = models.TextField(choices=AuthenticationMode.choices) provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE) + maximum_connections = models.IntegerField(default=1) property_mappings = models.ManyToManyField( "authentik_core.PropertyMapping", default=None, blank=True diff --git a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py index 0a659bccd..3000b345c 100644 --- a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py +++ b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py @@ -81,6 +81,7 @@ class TestEndpointsAPI(APITestCase): }, "protocol": "rdp", "host": self.allowed.host, + "maximum_connections": 1, "settings": {}, "property_mappings": [], "auth_mode": "", @@ -131,6 +132,7 @@ class TestEndpointsAPI(APITestCase): }, "protocol": "rdp", "host": self.allowed.host, + "maximum_connections": 1, "settings": {}, "property_mappings": [], "auth_mode": "", @@ -158,6 +160,7 @@ class TestEndpointsAPI(APITestCase): }, "protocol": "rdp", "host": self.denied.host, + "maximum_connections": 1, "settings": {}, "property_mappings": [], "auth_mode": "", diff --git a/authentik/enterprise/providers/rac/views.py b/authentik/enterprise/providers/rac/views.py index e50f6ee5b..4b93aee76 100644 --- a/authentik/enterprise/providers/rac/views.py +++ b/authentik/enterprise/providers/rac/views.py @@ -5,6 +5,7 @@ from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.timezone import now +from django.utils.translation import gettext as _ from authentik.core.models import Application, AuthenticatedSession from authentik.core.views.interface import InterfaceView @@ -79,35 +80,50 @@ class RACInterface(InterfaceView): class RACFinalStage(RedirectStage): """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: - endpoint: Endpoint = self.executor.current_stage.endpoint - engine = PolicyEngine(endpoint, self.request.user, self.request) + self.endpoint = self.executor.current_stage.endpoint + 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.build() passing = engine.result if not passing.passing: 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) def get_challenge(self, *args, **kwargs) -> RedirectChallenge: - endpoint: Endpoint = self.executor.current_stage.endpoint - provider: RACProvider = self.executor.current_stage.provider - application: Application = self.executor.current_stage.application token = ConnectionToken.objects.create( - provider=provider, - endpoint=endpoint, + provider=self.provider, + endpoint=self.endpoint, settings=self.executor.plan.context.get("connection_settings", {}), session=AuthenticatedSession.objects.filter( session_key=self.request.session.session_key ).first(), - expires=now() + timedelta_from_string(provider.connection_expiry), + expires=now() + timedelta_from_string(self.provider.connection_expiry), expiring=True, ) Event.new( EventAction.AUTHORIZE_APPLICATION, - authorized_application=application, + authorized_application=self.application, flow=self.executor.plan.flow_pk, - endpoint=endpoint.name, + endpoint=self.endpoint.name, ).from_http(self.request) setattr( self.executor.current_stage, diff --git a/blueprints/schema.json b/blueprints/schema.json index 07d9cd227..213cb1673 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -8958,6 +8958,12 @@ "prompt" ], "title": "Auth mode" + }, + "maximum_connections": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "title": "Maximum connections" } }, "required": [] diff --git a/schema.yml b/schema.yml index 09b351707..ed6a46fed 100644 --- a/schema.yml +++ b/schema.yml @@ -31381,6 +31381,10 @@ components: Build actual launch URL (the provider itself does not have one, just individual endpoints) readOnly: true + maximum_connections: + type: integer + maximum: 2147483647 + minimum: -2147483648 required: - auth_mode - host @@ -31412,6 +31416,10 @@ components: format: uuid auth_mode: $ref: '#/components/schemas/AuthModeEnum' + maximum_connections: + type: integer + maximum: 2147483647 + minimum: -2147483648 required: - auth_mode - host @@ -37298,6 +37306,10 @@ components: format: uuid auth_mode: $ref: '#/components/schemas/AuthModeEnum' + maximum_connections: + type: integer + maximum: 2147483647 + minimum: -2147483648 PatchedEventMatcherPolicyRequest: type: object description: Event Matcher Policy Serializer diff --git a/web/src/admin/outposts/OutpostForm.ts b/web/src/admin/outposts/OutpostForm.ts index 2c5ac9722..7c6c9dda5 100644 --- a/web/src/admin/outposts/OutpostForm.ts +++ b/web/src/admin/outposts/OutpostForm.ts @@ -3,6 +3,7 @@ import { docLink } from "@goauthentik/common/global"; import { groupBy } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; @@ -220,24 +221,27 @@ export class OutpostForm extends ModelForm { ${msg("Hold control/command to select multiple items.")}

- - -

- ${msg("Set custom attributes using YAML or JSON.")} -

-

- ${msg("See more here:")}  - ${msg("Documentation")} -

-
`; + + ${msg("Advanced settings")} + + +

+ ${msg("Set custom attributes using YAML or JSON.")} +

+

+ ${msg("See more here:")}  + ${msg("Documentation")} +

+
+
`; } } diff --git a/web/src/admin/providers/rac/EndpointForm.ts b/web/src/admin/providers/rac/EndpointForm.ts index af83af23f..0f23f4fca 100644 --- a/web/src/admin/providers/rac/EndpointForm.ts +++ b/web/src/admin/providers/rac/EndpointForm.ts @@ -106,6 +106,23 @@ export class EndpointForm extends ModelForm { />

${msg("Hostname/IP to connect to.")}

+ + +

+ ${msg( + "Maximum concurrent allowed connections to this endpoint. Can be set to -1 to disable the limit.", + )} +

+
Date: Thu, 4 Jan 2024 10:27:57 -0500 Subject: [PATCH 5/8] Update index.md (#8056) Signed-off-by: Bryan J. <132493975+chkpwd@users.noreply.github.com> --- .../integrations/services/jellyfin/index.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/website/integrations/services/jellyfin/index.md b/website/integrations/services/jellyfin/index.md index 5de18d3b4..ef66efd27 100644 --- a/website/integrations/services/jellyfin/index.md +++ b/website/integrations/services/jellyfin/index.md @@ -15,7 +15,7 @@ Jellyfin does not have any native external authentication support as of the writ ::: :::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 @@ -34,49 +34,49 @@ The following placeholders will be used: ## 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. - 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. 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" 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: - `LDAP Server`: `ldap.company.com` - `LDAP Port`: 636 - `Secure LDAP`: **Checked** - `StartTLS`: Unchecked - `Skip SSL/TLS Verification`: - - 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 using a certificate issued by a certificate authority, Jellyfin trusts, leave this unchecked. + - If you're using a self-signed certificate, check this box. - `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 - - `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 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. -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)` - `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 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) -- `LDAP Admin Base DN`: All of the users in this DN are automatically set as admins. - - This can be left blank. Admins can be set manually outside of this filter +- `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 this filter - `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)` - `LDAP Attributes`: `uid, cn, mail, displayName` - `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` - `Enabled User Creation`: **Checked** From 1b36cb833118e58808fcd69bb44eb1ca462b5fd4 Mon Sep 17 00:00:00 2001 From: "authentik-automation[bot]" <135050075+authentik-automation[bot]@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:29:57 +0100 Subject: [PATCH 6/8] web: bump API Client version (#8059) Signed-off-by: GitHub Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com> --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 945ad5ee2..7cf43ae96 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@formatjs/intl-listformat": "^7.5.3", "@fortawesome/fontawesome-free": "^6.5.1", - "@goauthentik/api": "^2023.10.5-1704381512", + "@goauthentik/api": "^2023.10.5-1704382057", "@lit-labs/context": "^0.4.0", "@lit-labs/task": "^3.1.0", "@lit/localize": "^0.11.4", @@ -2913,9 +2913,9 @@ } }, "node_modules/@goauthentik/api": { - "version": "2023.10.5-1704381512", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.10.5-1704381512.tgz", - "integrity": "sha512-kB7OZCwNEKvlVzzEkiA3bx9qIJ4OA44qmQZQ03iI05DKIJWqU+CssDyHeMUGv5Vdma04xdf+3+BOzrc+ZJWX+A==" + "version": "2023.10.5-1704382057", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.10.5-1704382057.tgz", + "integrity": "sha512-nzmAQgTrFXiOwKDeHhq1gAfIMRilAcDPmNvjhqoQc3GQfWs5GG2lAGzIewWyxsfxNABsg+I0BYTPxN7ffD6tXw==" }, "node_modules/@hcaptcha/types": { "version": "1.0.3", diff --git a/web/package.json b/web/package.json index 78a4f32ef..0a27a1c86 100644 --- a/web/package.json +++ b/web/package.json @@ -42,7 +42,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@formatjs/intl-listformat": "^7.5.3", "@fortawesome/fontawesome-free": "^6.5.1", - "@goauthentik/api": "^2023.10.5-1704381512", + "@goauthentik/api": "^2023.10.5-1704382057", "@lit-labs/context": "^0.4.0", "@lit-labs/task": "^3.1.0", "@lit/localize": "^0.11.4", From 509b502d3c4abafbb9df7f6a106849f8e1b948e6 Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 4 Jan 2024 19:57:11 +0100 Subject: [PATCH 7/8] providers/oauth2: offline access (#8026) * improve scope check (log when application requests non-configured scopes) Signed-off-by: Jens Langhammer * add offline_access special scope Signed-off-by: Jens Langhammer * ensure scope is set Signed-off-by: Jens Langhammer * update tests for refresh tokens Signed-off-by: Jens Langhammer * special handling of scopes for github compat Signed-off-by: Jens Langhammer * fix spec Signed-off-by: Jens Langhammer * attempt to fix oidc tests Signed-off-by: Jens Langhammer * remove hardcoded slug Signed-off-by: Jens Langhammer * check scope from authorization code instead of request Signed-off-by: Jens Langhammer * fix injection for consent stage checking incorrectly Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/providers/oauth2/constants.py | 12 +- authentik/providers/oauth2/errors.py | 2 +- .../providers/oauth2/tests/test_authorize.py | 35 +++++- .../providers/oauth2/tests/test_token.py | 42 ++++++- authentik/providers/oauth2/urls_root.py | 2 +- authentik/providers/oauth2/views/authorize.py | 73 +++++++++---- authentik/providers/oauth2/views/token.py | 103 ++++++++++-------- blueprints/system/providers-oauth2.yaml | 11 ++ tests/e2e/test_provider_oauth2_github.py | 18 +-- tests/e2e/test_provider_oauth2_grafana.py | 88 ++++++++------- tests/e2e/test_provider_oidc.py | 77 ++++++++----- tests/e2e/test_provider_oidc_implicit.py | 53 +++++---- .../providers/oauth2/OAuth2ProviderForm.ts | 8 +- website/docs/providers/oauth2/index.md | 10 +- website/docs/releases/2024/v2024.1.md | 6 + 15 files changed, 369 insertions(+), 171 deletions(-) diff --git a/authentik/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py index 81460b707..baaebbd60 100644 --- a/authentik/providers/oauth2/constants.py +++ b/authentik/providers/oauth2/constants.py @@ -7,8 +7,8 @@ GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" GRANT_TYPE_PASSWORD = "password" # nosec GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" -CLIENT_ASSERTION_TYPE = "client_assertion_type" CLIENT_ASSERTION = "client_assertion" +CLIENT_ASSERTION_TYPE = "client_assertion_type" CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" PROMPT_NONE = "none" @@ -18,9 +18,9 @@ PROMPT_LOGIN = "login" SCOPE_OPENID = "openid" SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_EMAIL = "email" +SCOPE_OFFLINE_ACCESS = "offline_access" -# https://www.iana.org/assignments/oauth-parameters/\ -# oauth-parameters.xhtml#pkce-code-challenge-method +# https://www.iana.org/assignments/oauth-parameters/auth-parameters.xhtml#pkce-code-challenge-method PKCE_METHOD_PLAIN = "plain" PKCE_METHOD_S256 = "S256" @@ -36,6 +36,12 @@ SCOPE_GITHUB_USER_READ = "read:user" SCOPE_GITHUB_USER_EMAIL = "user:email" # Read info about teams 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" diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 1788c9370..fd124cb91 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -127,7 +127,7 @@ class AuthorizeError(OAuth2Error): "account_selection_required": ( "The End-User is required to select a session at the Authorization Server" ), - "consent_required": "The Authorization Server requires End-Userconsent", + "consent_required": "The Authorization Server requires End-User consent", "invalid_request_uri": ( "The request_uri in the Authorization Request returns an error or contains invalid data" ), diff --git a/authentik/providers/oauth2/tests/test_authorize.py b/authentik/providers/oauth2/tests/test_authorize.py index 91cdc330a..7903c685c 100644 --- a/authentik/providers/oauth2/tests/test_authorize.py +++ b/authentik/providers/oauth2/tests/test_authorize.py @@ -5,6 +5,7 @@ from django.test import RequestFactory from django.urls import reverse from django.utils.timezone import now +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.events.models import Event, EventAction @@ -18,6 +19,7 @@ from authentik.providers.oauth2.models import ( AuthorizationCode, GrantTypes, OAuth2Provider, + ScopeMapping, ) from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams @@ -172,14 +174,24 @@ class TestAuthorize(OAuthTestCase): ) OAuthAuthorizationParams.from_request(request) + @apply_blueprint("system/providers-oauth2.yaml") def test_response_type(self): """test response_type""" - OAuth2Provider.objects.create( + provider = OAuth2Provider.objects.create( name=generate_id(), client_id="test", authorization_flow=create_test_flow(), 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( "/", data={ @@ -292,6 +304,7 @@ class TestAuthorize(OAuthTestCase): delta=5, ) + @apply_blueprint("system/providers-oauth2.yaml") def test_full_implicit(self): """Test full authorization""" flow = create_test_flow() @@ -302,6 +315,15 @@ class TestAuthorize(OAuthTestCase): redirect_uris="http://localhost", 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) state = generate_id() user = create_test_admin_user() @@ -409,6 +431,7 @@ class TestAuthorize(OAuthTestCase): delta=5, ) + @apply_blueprint("system/providers-oauth2.yaml") def test_full_form_post_id_token(self): """Test full authorization (form_post response)""" flow = create_test_flow() @@ -419,6 +442,15 @@ class TestAuthorize(OAuthTestCase): redirect_uris="http://localhost", 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) state = generate_id() user = create_test_admin_user() @@ -440,6 +472,7 @@ class TestAuthorize(OAuthTestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), ) token: AccessToken = AccessToken.objects.filter(user=user).first() + self.assertIsNotNone(token) self.assertJSONEqual( response.content.decode(), { diff --git a/authentik/providers/oauth2/tests/test_token.py b/authentik/providers/oauth2/tests/test_token.py index 79b3b13fe..5904d38be 100644 --- a/authentik/providers/oauth2/tests/test_token.py +++ b/authentik/providers/oauth2/tests/test_token.py @@ -6,6 +6,7 @@ from django.test import RequestFactory from django.urls import reverse from django.utils import timezone +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.events.models import Event, EventAction @@ -21,6 +22,7 @@ from authentik.providers.oauth2.models import ( AuthorizationCode, OAuth2Provider, RefreshToken, + ScopeMapping, ) from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.views.token import TokenParams @@ -136,21 +138,20 @@ class TestToken(OAuthTestCase): HTTP_AUTHORIZATION=f"Basic {header}", ) access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() - refresh: RefreshToken = RefreshToken.objects.filter(user=user, provider=provider).first() self.assertJSONEqual( response.content.decode(), { "access_token": access.token, - "refresh_token": refresh.token, "token_type": TOKEN_TYPE, "expires_in": 3600, "id_token": provider.encode( - refresh.id_token.to_dict(), + access.id_token.to_dict(), ), }, ) self.validate_jwt(access, provider) + @apply_blueprint("system/providers-oauth2.yaml") def test_refresh_token_view(self): """test request param""" provider = OAuth2Provider.objects.create( @@ -159,6 +160,16 @@ class TestToken(OAuthTestCase): redirect_uris="http://local.invalid", 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 self.app.provider = provider self.app.save() @@ -170,6 +181,7 @@ class TestToken(OAuthTestCase): token=generate_id(), _id_token=dumps({}), auth_time=timezone.now(), + _scope="offline_access", ) response = self.client.post( reverse("authentik_providers_oauth2:token"), @@ -201,6 +213,7 @@ class TestToken(OAuthTestCase): ) self.validate_jwt(access, provider) + @apply_blueprint("system/providers-oauth2.yaml") def test_refresh_token_view_invalid_origin(self): """test request param""" provider = OAuth2Provider.objects.create( @@ -209,6 +222,16 @@ class TestToken(OAuthTestCase): redirect_uris="http://local.invalid", 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() user = create_test_admin_user() token: RefreshToken = RefreshToken.objects.create( @@ -217,6 +240,7 @@ class TestToken(OAuthTestCase): token=generate_id(), _id_token=dumps({}), auth_time=timezone.now(), + _scope="offline_access", ) response = self.client.post( reverse("authentik_providers_oauth2:token"), @@ -247,6 +271,7 @@ class TestToken(OAuthTestCase): }, ) + @apply_blueprint("system/providers-oauth2.yaml") def test_refresh_token_revoke(self): """test request param""" provider = OAuth2Provider.objects.create( @@ -255,6 +280,16 @@ class TestToken(OAuthTestCase): redirect_uris="http://testserver", 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 self.app.provider = provider self.app.save() @@ -266,6 +301,7 @@ class TestToken(OAuthTestCase): token=generate_id(), _id_token=dumps({}), auth_time=timezone.now(), + _scope="offline_access", ) # Create initial refresh token response = self.client.post( diff --git a/authentik/providers/oauth2/urls_root.py b/authentik/providers/oauth2/urls_root.py index b00a90de4..9f7fe05ac 100644 --- a/authentik/providers/oauth2/urls_root.py +++ b/authentik/providers/oauth2/urls_root.py @@ -10,7 +10,7 @@ from authentik.providers.oauth2.views.token import TokenView github_urlpatterns = [ path( "login/oauth/authorize", - AuthorizationFlowInitView.as_view(), + AuthorizationFlowInitView.as_view(github_compat=True), name="github-authorize", ), path( diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index 520f02d65..7ad76a642 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -1,5 +1,5 @@ """authentik OAuth2 Authorization views""" -from dataclasses import dataclass, field +from dataclasses import InitVar, dataclass, field from datetime import timedelta from hashlib import sha256 from json import dumps @@ -41,6 +41,8 @@ from authentik.providers.oauth2.constants import ( PROMPT_CONSENT, PROMPT_LOGIN, PROMPT_NONE, + SCOPE_GITHUB, + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, TOKEN_TYPE, ) @@ -66,7 +68,6 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage from authentik.stages.consent.stage import ( PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_PERMISSIONS, - ConsentStageView, ) LOGGER = get_logger() @@ -86,7 +87,7 @@ class OAuthAuthorizationParams: redirect_uri: str response_type: str response_mode: Optional[str] - scope: list[str] + scope: set[str] state: str nonce: Optional[str] prompt: set[str] @@ -101,8 +102,10 @@ class OAuthAuthorizationParams: code_challenge: Optional[str] = None code_challenge_method: Optional[str] = None + github_compat: InitVar[bool] = False + @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 (and also for the Implicit and Hybrid). @@ -154,7 +157,7 @@ class OAuthAuthorizationParams: response_type=response_type, response_mode=response_mode, grant_type=grant_type, - scope=query_dict.get("scope", "").split(), + scope=set(query_dict.get("scope", "").split()), state=state, nonce=query_dict.get("nonce"), 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, code_challenge=query_dict.get("code_challenge"), 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( client_id=self.client_id ).first() @@ -172,7 +176,7 @@ class OAuthAuthorizationParams: LOGGER.warning("Invalid client identifier", client_id=self.client_id) raise ClientIdError(client_id=self.client_id) self.check_redirect_uri() - self.check_scope() + self.check_scope(github_compat) self.check_nonce() self.check_code_challenge() @@ -199,8 +203,8 @@ class OAuthAuthorizationParams: if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls): LOGGER.warning( "Invalid redirect uri (regex comparison)", - redirect_uri=self.redirect_uri, - expected=allowed_redirect_urls, + redirect_uri_given=self.redirect_uri, + redirect_uri_expected=allowed_redirect_urls, ) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) except RegexError as exc: @@ -208,8 +212,8 @@ class OAuthAuthorizationParams: if not any(x == self.redirect_uri for x in allowed_redirect_urls): LOGGER.warning( "Invalid redirect uri (strict comparison)", - redirect_uri=self.redirect_uri, - expected=allowed_redirect_urls, + redirect_uri_given=self.redirect_uri, + redirect_uri_expected=allowed_redirect_urls, ) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) if self.request: @@ -217,24 +221,50 @@ class OAuthAuthorizationParams: 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""" - if len(self.scope) == 0: - default_scope_names = set( - ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( - "scope_name", flat=True - ) + default_scope_names = set( + ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( + "scope_name", flat=True ) + ) + if len(self.scope) == 0: self.scope = default_scope_names LOGGER.info( "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 ( self.grant_type == GrantTypes.HYBRID or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN] ): LOGGER.warning("Missing 'openid' scope.") 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): """Nonce parameter validation.""" @@ -297,6 +327,9 @@ class AuthorizationFlowInitView(PolicyAccessView): """OAuth2 Flow initializer, checks access to application and starts flow""" params: OAuthAuthorizationParams + # Enable GitHub compatibility (only allow for scopes which are handled + # differently for github compat) + github_compat = False def pre_permission_check(self): """Check prompt parameter before checking permission/authentication, @@ -305,7 +338,9 @@ class AuthorizationFlowInitView(PolicyAccessView): if len(self.request.GET) < 1: raise Http404 try: - self.params = OAuthAuthorizationParams.from_request(self.request) + self.params = OAuthAuthorizationParams.from_request( + self.request, github_compat=self.github_compat + ) except AuthorizeError as error: LOGGER.warning(error.description, redirect_uri=error.redirect_uri) 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 # need to inject a consent stage 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 stage = ConsentStage( name="OAuth2 Provider In-memory consent stage", diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 93578d6ce..78931b6c8 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -41,6 +41,7 @@ from authentik.providers.oauth2.constants import ( GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN, PKCE_METHOD_S256, + SCOPE_OFFLINE_ACCESS, TOKEN_TYPE, ) from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError @@ -459,7 +460,7 @@ class TokenView(View): op="authentik.providers.oauth2.post.response", ): 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()) if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: LOGGER.debug("Refreshing refresh token") @@ -496,42 +497,47 @@ class TokenView(View): ) access_token.save() - refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) - refresh_token = RefreshToken( - user=self.params.authorization_code.user, - scope=self.params.authorization_code.scope, - expires=refresh_token_expiry, - provider=self.provider, - auth_time=self.params.authorization_code.auth_time, - session_id=self.params.authorization_code.session_id, - ) - id_token = IDToken.new( - self.provider, - refresh_token, - self.request, - ) - id_token.nonce = self.params.authorization_code.nonce - id_token.at_hash = access_token.at_hash - refresh_token.id_token = id_token - refresh_token.save() - - # Delete old code - self.params.authorization_code.delete() - 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), + "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 = RefreshToken( + user=self.params.authorization_code.user, + scope=self.params.authorization_code.scope, + expires=refresh_token_expiry, + provider=self.provider, + auth_time=self.params.authorization_code.auth_time, + session_id=self.params.authorization_code.session_id, + ) + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, + ) + id_token.nonce = self.params.authorization_code.nonce + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token + refresh_token.save() + response["refresh_token"] = refresh_token.token + + # Delete old code + self.params.authorization_code.delete() + return response + def create_refresh_response(self) -> dict[str, Any]: """See https://datatracker.ietf.org/doc/html/rfc6749#section-6""" unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) if unauthorized_scopes: raise TokenError("invalid_scope") + if SCOPE_OFFLINE_ACCESS not in self.params.scope: + raise TokenError("invalid_scope") now = timezone.now() access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token = AccessToken( @@ -630,31 +636,34 @@ class TokenView(View): ) access_token.save() - refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) - refresh_token = RefreshToken( - user=self.params.device_code.user, - scope=self.params.device_code.scope, - expires=refresh_token_expiry, - provider=self.provider, - auth_time=auth_event.created if auth_event else now, - ) - id_token = IDToken.new( - self.provider, - refresh_token, - self.request, - ) - id_token.at_hash = access_token.at_hash - refresh_token.id_token = id_token - refresh_token.save() - - # Delete device code - self.params.device_code.delete() - 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), + "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 = RefreshToken( + user=self.params.device_code.user, + scope=self.params.device_code.scope, + expires=refresh_token_expiry, + provider=self.provider, + auth_time=auth_event.created if auth_event else now, + ) + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, + ) + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token + refresh_token.save() + response["refresh_token"] = refresh_token.token + + # Delete device code + self.params.device_code.delete() + return response diff --git a/blueprints/system/providers-oauth2.yaml b/blueprints/system/providers-oauth2.yaml index eb4ca5442..0d2448202 100644 --- a/blueprints/system/providers-oauth2.yaml +++ b/blueprints/system/providers-oauth2.yaml @@ -45,3 +45,14 @@ entries: # 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()], } + - 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 {} diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index 5e19dd146..5095421a8 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -74,7 +74,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_id=self.client_id, client_secret=self.client_secret, client_type=ClientTypes.CONFIDENTIAL, @@ -82,8 +82,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): authorization_flow=authorization_flow, ) Application.objects.create( - name="Grafana", - slug="grafana", + name=generate_id(), + slug=generate_id(), provider=provider, ) @@ -129,7 +129,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_id=self.client_id, client_secret=self.client_secret, client_type=ClientTypes.CONFIDENTIAL, @@ -137,8 +137,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): authorization_flow=authorization_flow, ) app = Application.objects.create( - name="Grafana", - slug="grafana", + name=generate_id(), + slug=generate_id(), provider=provider, ) @@ -200,7 +200,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_id=self.client_id, client_secret=self.client_secret, client_type=ClientTypes.CONFIDENTIAL, @@ -208,8 +208,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): authorization_flow=authorization_flow, ) app = Application.objects.create( - name="Grafana", - slug="grafana", + name=generate_id(), + slug=generate_id(), provider=provider, ) diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py index 2538fae70..fa8f12d1d 100644 --- a/tests/e2e/test_provider_oauth2_grafana.py +++ b/tests/e2e/test_provider_oauth2_grafana.py @@ -14,6 +14,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, @@ -80,7 +81,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, @@ -90,12 +91,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( 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( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -113,12 +119,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_consent_implied(self): """test OpenID Provider flow (default authorization flow with implied consent)""" @@ -128,7 +130,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, @@ -138,11 +140,16 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( 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( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -174,12 +181,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_logout(self): """test OpenID Provider flow with logout""" @@ -189,7 +192,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, @@ -199,12 +202,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( 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( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -244,12 +252,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" @@ -259,7 +263,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), authorization_flow=authorization_flow, client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, @@ -269,12 +273,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( 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( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -323,12 +332,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_denied(self): """test OpenID Provider flow (default authorization with access deny)""" @@ -338,7 +343,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), authorization_flow=authorization_flow, client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, @@ -348,12 +353,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( 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( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) diff --git a/tests/e2e/test_provider_oidc.py b/tests/e2e/test_provider_oidc.py index 3180f9534..c14a498a6 100644 --- a/tests/e2e/test_provider_oidc.py +++ b/tests/e2e/test_provider_oidc.py @@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, @@ -29,7 +30,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): def setUp(self): self.client_id = generate_id() self.client_secret = generate_key() - self.application_slug = "test" + self.application_slug = generate_id() super().setUp() def setup_client(self) -> Container: @@ -37,7 +38,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): sleep(1) client: DockerClient = from_env() container = client.containers.run( - image="ghcr.io/beryju/oidc-test-client:1.3", + image="ghcr.io/beryju/oidc-test-client:2.1", detach=True, ports={ "9009": "9009", @@ -56,9 +57,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") @reconcile_app("authentik_crypto") def test_redirect_uri_error(self): """test OpenID Provider flow (invalid redirect URI, check error message)""" @@ -78,10 +77,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( 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( name=self.application_slug, slug=self.application_slug, @@ -101,13 +104,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") 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) # Bootstrap all needed objects authorization_flow = Flow.objects.get( @@ -124,11 +126,15 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( 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( + app = Application.objects.create( name=self.application_slug, slug=self.application_slug, provider=provider, @@ -137,6 +143,20 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): self.driver.get("http://localhost:9009") 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.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) 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-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" sleep(1) @@ -178,10 +196,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( 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( name=self.application_slug, slug=self.application_slug, @@ -224,9 +246,8 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_denied(self): """test OpenID Provider flow (default authorization with access deny)""" @@ -246,10 +267,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( 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( name=self.application_slug, slug=self.application_slug, diff --git a/tests/e2e/test_provider_oidc_implicit.py b/tests/e2e/test_provider_oidc_implicit.py index c5d9d37d0..37dff0aa3 100644 --- a/tests/e2e/test_provider_oidc_implicit.py +++ b/tests/e2e/test_provider_oidc_implicit.py @@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, @@ -37,7 +38,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): sleep(1) client: DockerClient = from_env() container = client.containers.run( - image="ghcr.io/beryju/oidc-test-client:1.3", + image="ghcr.io/beryju/oidc-test-client:2.1", detach=True, ports={ "9009": "9009", @@ -56,9 +57,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_redirect_uri_error(self): """test OpenID Provider flow (invalid redirect URI, check error message)""" @@ -78,7 +78,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( 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() @@ -101,11 +106,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_implied(self): """test OpenID Provider flow (default authorization flow with implied consent)""" sleep(1) @@ -124,7 +127,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( 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() @@ -150,11 +158,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" sleep(1) @@ -173,7 +179,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( 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() @@ -215,9 +226,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_denied(self): """test OpenID Provider flow (default authorization with access deny)""" @@ -237,7 +247,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( 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() diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 000df8cad..74f3acbeb 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -290,9 +290,13 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { let selected = false; if (!provider?.propertyMappings) { selected = - scope.managed?.startsWith( + // By default select all managed scope mappings, except offline_access + (scope.managed?.startsWith( "goauthentik.io/providers/oauth2/scope-", - ) || false; + ) && + scope.managed !== + "goauthentik.io/providers/oauth2/scope-offline_access") || + false; } else { selected = Array.from(provider?.propertyMappings).some((su) => { return su == scope.pk; diff --git a/website/docs/providers/oauth2/index.md b/website/docs/providers/oauth2/index.md index 0eba63765..90ce75778 100644 --- a/website/docs/providers/oauth2/index.md +++ b/website/docs/providers/oauth2/index.md @@ -35,12 +35,20 @@ To access the user's email address, a scope of `user:email` is required. To acce ### `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 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`: See [Machine-to-machine authentication](./client_credentials) diff --git a/website/docs/releases/2024/v2024.1.md b/website/docs/releases/2024/v2024.1.md index c6ffd0f28..e9f31fc55 100644 --- a/website/docs/releases/2024/v2024.1.md +++ b/website/docs/releases/2024/v2024.1.md @@ -17,6 +17,12 @@ slug: "/releases/2024.1" - `authentik_outpost_radius_requests_rejected` -> `authentik_outpost_radius_requests_rejected_total` - `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 - "Pretend user exists" option for Identification stage From 827591d3763f90b92328a3def3b14555ac5a4227 Mon Sep 17 00:00:00 2001 From: Tana M Berry Date: Thu, 4 Jan 2024 13:32:44 -0600 Subject: [PATCH 8/8] website/docs: add link to our example flows (#8052) add link to our example flows Co-authored-by: Tana Berry --- website/docs/flow/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/docs/flow/index.md b/website/docs/flow/index.md index d13ea1e5e..0fd0c510c 100644 --- a/website/docs/flow/index.md +++ b/website/docs/flow/index.md @@ -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. +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.