diff --git a/requirements/base.txt b/requirements/base.txt
index 1c7c145fb4..ca007fc5ec 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -155,7 +155,7 @@ django-csp==3.7
# via -r requirements/base.in
django-csp-reports==1.8.1
# via -r requirements/base.in
-django-digid-eherkenning==0.12.0
+django-digid-eherkenning==0.13.0
# via -r requirements/base.in
django-filter==23.2
# via -r requirements/base.in
diff --git a/requirements/ci.txt b/requirements/ci.txt
index ffe7384cfd..dd4f45b5de 100644
--- a/requirements/ci.txt
+++ b/requirements/ci.txt
@@ -258,7 +258,7 @@ django-csp-reports==1.8.1
# via
# -c requirements/base.txt
# -r requirements/base.txt
-django-digid-eherkenning==0.12.0
+django-digid-eherkenning==0.13.0
# via
# -c requirements/base.txt
# -r requirements/base.txt
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 54e2e32259..37f5d49168 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -287,7 +287,7 @@ django-csp-reports==1.8.1
# -r requirements/ci.txt
django-debug-toolbar==4.3.0
# via -r requirements/dev.in
-django-digid-eherkenning==0.12.0
+django-digid-eherkenning==0.13.0
# via
# -c requirements/ci.txt
# -r requirements/ci.txt
diff --git a/requirements/extensions.txt b/requirements/extensions.txt
index d49c80f0c5..f85feedd33 100644
--- a/requirements/extensions.txt
+++ b/requirements/extensions.txt
@@ -223,7 +223,7 @@ django-csp-reports==1.8.1
# via
# -c requirements/base.in
# -r requirements/base.txt
-django-digid-eherkenning==0.12.0
+django-digid-eherkenning==0.13.0
# via
# -c requirements/base.in
# -r requirements/base.txt
diff --git a/src/openapi.yaml b/src/openapi.yaml
index 309caaccce..31f75ff0ae 100644
--- a/src/openapi.yaml
+++ b/src/openapi.yaml
@@ -6716,6 +6716,10 @@ components:
type: string
title: Provides authentication attributes
description: The authentication attribute provided by this plugin.
+ supportsLoaOverride:
+ type: boolean
+ description: Does the Identity Provider support overriding the minimum Level
+ of Assurance (LoA) through the authentication request?
assuranceLevels:
type: array
items:
@@ -6727,6 +6731,7 @@ components:
- id
- label
- providesAuth
+ - supportsLoaOverride
AuthenticationBackendsEnum:
enum:
- digid
diff --git a/src/openforms/authentication/api/serializers.py b/src/openforms/authentication/api/serializers.py
index 708128a844..0d3c046044 100644
--- a/src/openforms/authentication/api/serializers.py
+++ b/src/openforms/authentication/api/serializers.py
@@ -23,6 +23,13 @@ class AuthPluginSerializer(PluginBaseSerializer):
label=_("Provides authentication attributes"),
help_text=_("The authentication attribute provided by this plugin."),
)
+ supports_loa_override = serializers.BooleanField(
+ label=_("supports loa override"),
+ help_text=_(
+ "Does the Identity Provider support overriding the minimum "
+ "Level of Assurance (LoA) through the authentication request?"
+ ),
+ )
assurance_levels = serializers.ListField(
child=TextChoiceSerializer(),
label=_("Levels of assurance"),
diff --git a/src/openforms/authentication/api/tests/test_endpoints.py b/src/openforms/authentication/api/tests/test_endpoints.py
index 01610ba9ca..a92f162186 100644
--- a/src/openforms/authentication/api/tests/test_endpoints.py
+++ b/src/openforms/authentication/api/tests/test_endpoints.py
@@ -98,6 +98,7 @@ def test_single_auth_plugin(self):
"id": "plugin1",
"label": "SingleAuthPlugin",
"providesAuth": "bsn",
+ "supportsLoaOverride": False,
"assuranceLevels": [
{"label": "low", "value": "low"},
{"label": "Stare into the Sun", "value": "∞"},
@@ -120,6 +121,7 @@ def test_demo_plugin(self):
"id": "plugin1",
"label": "SingleAuthPlugin",
"providesAuth": "bsn",
+ "supportsLoaOverride": False,
"assuranceLevels": [
{"label": "low", "value": "low"},
{"label": "Stare into the Sun", "value": "∞"},
@@ -129,6 +131,7 @@ def test_demo_plugin(self):
"id": "plugin2",
"label": "DemoAuthPlugin",
"providesAuth": "bsn",
+ "supportsLoaOverride": False,
"assuranceLevels": [],
},
]
diff --git a/src/openforms/authentication/base.py b/src/openforms/authentication/base.py
index 48cdd76958..a18efc58ab 100644
--- a/src/openforms/authentication/base.py
+++ b/src/openforms/authentication/base.py
@@ -39,6 +39,7 @@ class Choice(TypedDict):
class BasePlugin(AbstractBasePlugin):
provides_auth: AuthAttribute
+ supports_loa_override = False
assurance_levels: type[TextChoices] = TextChoices
return_method = "GET"
is_for_gemachtigde = False
diff --git a/src/openforms/authentication/contrib/digid/plugin.py b/src/openforms/authentication/contrib/digid/plugin.py
index 023bba30dd..6ccfa9ba4a 100644
--- a/src/openforms/authentication/contrib/digid/plugin.py
+++ b/src/openforms/authentication/contrib/digid/plugin.py
@@ -34,6 +34,7 @@ def loa_order(loa: str) -> int:
class DigidAuthentication(BasePlugin):
verbose_name = _("DigiD")
provides_auth = AuthAttribute.bsn
+ supports_loa_override = True
assurance_levels = DigiDAssuranceLevels
def start_login(
diff --git a/src/openforms/authentication/contrib/eherkenning/plugin.py b/src/openforms/authentication/contrib/eherkenning/plugin.py
index 9bc04de230..8e8da917b1 100644
--- a/src/openforms/authentication/contrib/eherkenning/plugin.py
+++ b/src/openforms/authentication/contrib/eherkenning/plugin.py
@@ -124,7 +124,6 @@ def logout(self, request: HttpRequest):
class EHerkenningAuthentication(AuthenticationBasePlugin):
verbose_name = _("eHerkenning")
provides_auth = AuthAttribute.kvk
- assurance_levels = AssuranceLevels
session_key = EHERKENNING_AUTH_SESSION_KEY
def get_session_loa(self, session) -> str:
@@ -134,7 +133,7 @@ def get_session_loa(self, session) -> str:
def check_requirements(self, request, config):
# check LoA requirements
authenticated_loa = request.session[FORM_AUTH_SESSION_KEY]["loa"]
- required = config.get("loa") or EherkenningConfiguration.get_solo().loa
+ required = EherkenningConfiguration.get_solo().eh_loa
return loa_order(authenticated_loa) >= loa_order(required)
def get_logo(self, request) -> LoginLogo | None:
diff --git a/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py b/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py
index 70807c4299..05914a0428 100644
--- a/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py
+++ b/src/openforms/authentication/contrib/eherkenning/tests/test_eherkenning_auth.py
@@ -9,7 +9,6 @@
from django.utils.safestring import mark_safe
import requests_mock
-from digid_eherkenning.choices import AssuranceLevels
from digid_eherkenning.models import EherkenningConfiguration
from freezegun import freeze_time
from furl import furl
@@ -180,70 +179,6 @@ def test_authn_request(self, mock_id):
},
)
- @freeze_time("2020-04-09T08:31:46Z")
- @patch(
- "onelogin.saml2.authn_request.OneLogin_Saml2_Utils.generate_unique_id",
- return_value="ONELOGIN_123456",
- )
- def test_authn_request_uses_minimal_loa_from_form(self, mock_id):
- form = FormFactory.create(
- authentication_backends=["eherkenning"],
- authentication_backend_options={
- "eherkenning": {"loa": AssuranceLevels.high}
- },
- generate_minimal_setup=True,
- formstep__form_definition__login_required=True,
- )
- login_url = reverse(
- "authentication:start",
- kwargs={"slug": form.slug, "plugin_id": "eherkenning"},
- )
- form_path = reverse("core:form-detail", kwargs={"slug": form.slug})
- form_url = f"https://testserver{form_path}"
- login_url = furl(login_url).set({"next": form_url})
-
- response = self.client.get(login_url.url, follow=True)
-
- return_url = reverse(
- "authentication:return",
- kwargs={"slug": form.slug, "plugin_id": "eherkenning"},
- )
- full_return_url = furl(return_url).add({"next": form_url})
-
- self.assertEqual(
- response.context["form"].initial["RelayState"],
- str(full_return_url),
- )
-
- saml_request = b64decode(
- response.context["form"].initial["SAMLRequest"].encode("utf-8")
- )
- tree = etree.fromstring(saml_request)
-
- self.assertEqual(
- tree.attrib,
- {
- "ID": "ONELOGIN_123456",
- "Version": "2.0",
- "ForceAuthn": "true",
- "IssueInstant": "2020-04-09T08:31:46Z",
- "Destination": "https://test-iwelcome.nl/broker/sso/1.13",
- "ProtocolBinding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact",
- "AssertionConsumerServiceURL": "https://test-sp.nl/eherkenning/acs/",
- "AttributeConsumingServiceIndex": "8888",
- },
- )
-
- auth_context_class_ref = tree.xpath(
- "samlp:RequestedAuthnContext[@Comparison='minimum']/saml:AuthnContextClassRef",
- namespaces={
- "samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
- "saml": "urn:oasis:names:tc:SAML:2.0:assertion",
- },
- )[0]
-
- self.assertEqual(auth_context_class_ref.text, AssuranceLevels.high.value)
-
@override_settings(CORS_ALLOW_ALL_ORIGINS=True)
@temp_private_root()
diff --git a/src/openforms/authentication/contrib/eherkenning/urls.py b/src/openforms/authentication/contrib/eherkenning/urls.py
index 4c246dc466..839a5ee829 100644
--- a/src/openforms/authentication/contrib/eherkenning/urls.py
+++ b/src/openforms/authentication/contrib/eherkenning/urls.py
@@ -1,6 +1,8 @@
from django.urls import path
-from .views import eHerkenningAssertionConsumerServiceView, eHerkenningLoginView
+from digid_eherkenning.views import eHerkenningLoginView
+
+from .views import eHerkenningAssertionConsumerServiceView
app_name = "eherkenning"
diff --git a/src/openforms/authentication/contrib/eherkenning/views.py b/src/openforms/authentication/contrib/eherkenning/views.py
index 016c0a8dbf..55711e9739 100644
--- a/src/openforms/authentication/contrib/eherkenning/views.py
+++ b/src/openforms/authentication/contrib/eherkenning/views.py
@@ -2,7 +2,6 @@
from typing import Any
from django.http import HttpResponseRedirect
-from django.shortcuts import get_object_or_404
from django.urls import resolve
from django.utils.translation import gettext as _
@@ -10,18 +9,14 @@
from digid_eherkenning.saml2.eherkenning import eHerkenningClient
from digid_eherkenning.views import (
eHerkenningAssertionConsumerServiceView as _eHerkenningAssertionConsumerServiceView,
- eHerkenningLoginView as _eHerkenningLoginView,
)
from furl import furl
from onelogin.saml2.errors import OneLogin_Saml2_ValidationError
-from openforms.forms.models import Form
-
from ..digid.mixins import AssertionConsumerServiceMixin
from .constants import (
EHERKENNING_AUTH_SESSION_AUTHN_CONTEXTS,
EHERKENNING_AUTH_SESSION_KEY,
- EHERKENNING_PLUGIN_ID,
EIDAS_AUTH_SESSION_KEY,
)
@@ -37,20 +32,6 @@ class ExpectedIdNotPresentError(Exception):
GENERIC_LOGIN_ERROR = "error"
-class eHerkenningLoginView(_eHerkenningLoginView):
- def get_level_of_assurance(self):
- # get the form_slug from /auth/{slug}/...?next=...
- return_path = furl(self.request.GET.get("next")).path
- _, _, kwargs = resolve(return_path)
-
- form = get_object_or_404(Form, slug=kwargs.get("slug"))
-
- loa = form.authentication_backend_options.get(EHERKENNING_PLUGIN_ID, {}).get(
- "loa"
- )
- return loa if loa else super().get_level_of_assurance()
-
-
class eHerkenningAssertionConsumerServiceView(
AssertionConsumerServiceMixin,
BaseSaml2Backend,
diff --git a/src/openforms/js/components/admin/form_design/FormConfigurationFields.js b/src/openforms/js/components/admin/form_design/FormConfigurationFields.js
index 1431692327..84c20bf25a 100644
--- a/src/openforms/js/components/admin/form_design/FormConfigurationFields.js
+++ b/src/openforms/js/components/admin/form_design/FormConfigurationFields.js
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
+import LoAOverrideOption from 'components/admin/form_design/authentication/LoAOverrideOption';
import Field from 'components/admin/forms/Field';
import Fieldset from 'components/admin/forms/Fieldset';
import FormRow from 'components/admin/forms/FormRow';
@@ -254,41 +255,12 @@ const FormConfigurationFields = ({
>
-
-
- }
- >
-
- {availableAuthPlugins
- .filter(
- plugin =>
- plugin.assuranceLevels.length && selectedAuthPlugins.includes(plugin.id)
- )
- .map(plugin => (
- -
-
-
- ))}
-
-
-
+
>
)}
diff --git a/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.js b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.js
new file mode 100644
index 0000000000..cfc5fa34c8
--- /dev/null
+++ b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import Field from 'components/admin/forms/Field';
+import FormRow from 'components/admin/forms/FormRow';
+import Select from 'components/admin/forms/Select';
+
+const LoAOverrideOption = ({
+ availableAuthPlugins,
+ selectedAuthPlugins,
+ authenticationBackendOptions,
+ onChange,
+}) => {
+ // These are the selected plugins that support overriding the LoA via the Authentication request
+ const pluginsToDisplay = availableAuthPlugins.filter(
+ plugin =>
+ selectedAuthPlugins.includes(plugin.id) &&
+ plugin.supportsLoaOverride &&
+ plugin.assuranceLevels.length
+ );
+
+ if (pluginsToDisplay.length === 0) return null;
+
+ return (
+
+
+ }
+ helpText={
+
+ }
+ >
+
+ {pluginsToDisplay.map(plugin => (
+ -
+
+
+ ))}
+
+
+
+ );
+};
+
+export default LoAOverrideOption;
+
+LoAOverrideOption.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ availableAuthPlugins: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string,
+ label: PropTypes.string,
+ providesAuth: PropTypes.string,
+ })
+ ).isRequired,
+ selectedAuthPlugins: PropTypes.arrayOf(PropTypes.string).isRequired,
+ authenticationBackendOptions: PropTypes.object,
+};
diff --git a/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.mdx b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.mdx
new file mode 100644
index 0000000000..e5e7049399
--- /dev/null
+++ b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.mdx
@@ -0,0 +1,18 @@
+import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs';
+
+import LoAOverrideOption from './LoAOverrideOption';
+import * as Stories from './LoAOverrideOption.stories';
+
+
+
+# Form field
+
+Some authentication plugins offer the option of overriding the Level of Assurance (LoA) per form.
+This form field is visible if one of these plugins has been selected. It is then possible to select
+the new minimum desired LoA for a form.
+
+
+
+### Props
+
+
diff --git a/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.stories.js b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.stories.js
new file mode 100644
index 0000000000..155285d8f7
--- /dev/null
+++ b/src/openforms/js/components/admin/form_design/authentication/LoAOverrideOption.stories.js
@@ -0,0 +1,116 @@
+import {expect} from '@storybook/jest';
+import {within} from '@storybook/testing-library';
+
+import LoAOverrideOption from './LoAOverrideOption';
+
+export default {
+ title: 'Form design/ Authentication / LoA override option',
+ component: LoAOverrideOption,
+};
+
+export const Default = {
+ args: {
+ availableAuthPlugins: [
+ {
+ id: 'digid',
+ label: 'DigiD',
+ providesAuth: 'bsn',
+ supportsLoaOverride: true,
+ assuranceLevels: [
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
+ label: 'DigiD Basis',
+ },
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract',
+ label: 'DigiD Midden',
+ },
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard',
+ label: 'DigiD Substantieel',
+ },
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI',
+ label: 'DigiD Hoog',
+ },
+ ],
+ },
+ {
+ id: 'eidas',
+ label: 'eIDAS',
+ providesAuth: 'pseudo',
+ supportsLoaOverride: false,
+ assuranceLevels: [],
+ },
+ ],
+ selectedAuthPlugins: ['digid', 'eidas'],
+ authenticationBackendOptions: {
+ digid: {loa: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'},
+ },
+ },
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement);
+
+ const fieldLabel = canvas.queryByText('Minimale betrouwbaarheidsniveaus');
+
+ await expect(fieldLabel).toBeVisible();
+
+ const dropdowns = canvas.getAllByRole('combobox');
+
+ await expect(dropdowns.length).toEqual(1);
+ },
+};
+
+export const NoDigiDSelected = {
+ name: 'No DigiD selceted',
+ args: {
+ availableAuthPlugins: [
+ {
+ id: 'digid',
+ label: 'DigiD',
+ providesAuth: 'bsn',
+ supportsLoaOverride: true,
+ assuranceLevels: [
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
+ label: 'DigiD Basis',
+ },
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract',
+ label: 'DigiD Midden',
+ },
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard',
+ label: 'DigiD Substantieel',
+ },
+ {
+ value: 'urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI',
+ label: 'DigiD Hoog',
+ },
+ ],
+ },
+ {
+ id: 'eidas',
+ label: 'eIDAS',
+ providesAuth: 'pseudo',
+ supportsLoaOverride: false,
+ assuranceLevels: [],
+ },
+ ],
+ selectedAuthPlugins: ['eidas'],
+ authenticationBackendOptions: {
+ digid: {loa: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'},
+ },
+ },
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement);
+
+ const fieldLabel = canvas.queryByText('Minimale betrouwbaarheidsniveaus');
+
+ await expect(fieldLabel).toBeNull();
+
+ const dropdowns = canvas.queryAllByRole('combobox');
+
+ await expect(dropdowns.length).toEqual(0);
+ },
+};
diff --git a/src/openforms/js/components/admin/form_design/types/AuthPlugin.js b/src/openforms/js/components/admin/form_design/types/AuthPlugin.js
index 22e0b95f82..2cef5dfd4e 100644
--- a/src/openforms/js/components/admin/form_design/types/AuthPlugin.js
+++ b/src/openforms/js/components/admin/form_design/types/AuthPlugin.js
@@ -4,6 +4,8 @@ const AuthPlugin = PropTypes.shape({
id: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
providesAuth: PropTypes.string,
+ assuranceLevel: PropTypes.array,
+ supportsLoaOverride: PropTypes.bool,
});
export default AuthPlugin;