Skip to content

Commit

Permalink
Merge pull request #72 from maykinmedia/feature/loa-claims-and-value-…
Browse files Browse the repository at this point in the history
…mapping

Support LOA claims and claim processing
  • Loading branch information
sergei-maertens authored Jun 24, 2024
2 parents 6c7bdf8 + 0b17cfe commit a0c9a84
Show file tree
Hide file tree
Showing 15 changed files with 981 additions and 24 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
Changelog
=========

0.15.0 (2024-06-??)
===================

Further iteration on the OIDC integration.

* 💥⚠️ Renamed the ``OpenIDConnectBaseConfig`` base model to ``BaseConfig``
* Added "level of assurance" claim configuration
* Added ability to specify a fallback LOA value
* Added ability to map claim values to their standard values
* Added ``digid_eherkenning.oidc.claims.process_claims`` helper to normalize received
claims from the OIDC provider for further processing. See the tests for the intended
behaviour.

0.14.0 (2024-06-13)
===================

Expand Down
24 changes: 20 additions & 4 deletions digid_eherkenning/oidc/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
from solo.admin import SingletonModelAdmin

from .models import (
BaseConfig,
DigiDConfig,
DigiDMachtigenConfig,
EHerkenningBewindvoeringConfig,
EHerkenningConfig,
OpenIDConnectBaseConfig,
)

# Using a dict because these retain ordering, and it makes things a bit more readable.
Expand Down Expand Up @@ -62,7 +62,7 @@
}


def admin_modelform_factory(model: type[OpenIDConnectBaseConfig], *args, **kwargs):
def admin_modelform_factory(model: type[BaseConfig], *args, **kwargs):
"""
Factory function to generate a model form class for a given configuration model.
Expand All @@ -80,7 +80,7 @@ def admin_modelform_factory(model: type[OpenIDConnectBaseConfig], *args, **kwarg
return Form


def fieldsets_factory(claim_mapping_fields: Sequence[str]):
def fieldsets_factory(claim_mapping_fields: Sequence[str | Sequence[str]]):
"""
Apply the shared fieldsets configuration with the model-specific overrides.
"""
Expand All @@ -92,7 +92,14 @@ def fieldsets_factory(claim_mapping_fields: Sequence[str]):
@admin.register(DigiDConfig)
class DigiDConfigAdmin(SingletonModelAdmin):
form = admin_modelform_factory(DigiDConfig)
fieldsets = fieldsets_factory(claim_mapping_fields=["bsn_claim"])
fieldsets = fieldsets_factory(
claim_mapping_fields=[
"bsn_claim",
"loa_claim",
"default_loa",
"loa_value_mapping",
]
)


@admin.register(EHerkenningConfig)
Expand All @@ -104,6 +111,9 @@ class EHerkenningConfigAdmin(SingletonModelAdmin):
"legal_subject_claim",
"branch_number_claim",
"acting_subject_claim",
"loa_claim",
"default_loa",
"loa_value_mapping",
]
)

Expand All @@ -115,6 +125,9 @@ class DigiDMachtigenConfigAdmin(SingletonModelAdmin):
claim_mapping_fields=[
"representee_bsn_claim",
"authorizee_bsn_claim",
"loa_claim",
"default_loa",
"loa_value_mapping",
"mandate_service_id_claim",
]
)
Expand All @@ -130,6 +143,9 @@ class EHerkenningBewindvoeringConfigAdmin(SingletonModelAdmin):
"legal_subject_claim",
"branch_number_claim",
"acting_subject_claim",
"loa_claim",
"default_loa",
"loa_value_mapping",
"mandate_service_id_claim",
"mandate_service_uuid_claim",
]
Expand Down
4 changes: 2 additions & 2 deletions digid_eherkenning/oidc/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from mozilla_django_oidc_db.backends import OIDCAuthenticationBackend
from mozilla_django_oidc_db.typing import JSONObject

from .models.base import OpenIDConnectBaseConfig
from .models.base import BaseConfig


class BaseBackend(OIDCAuthenticationBackend):
def _check_candidate_backend(self) -> bool:
suitable_model = issubclass(self.config_class, OpenIDConnectBaseConfig)
suitable_model = issubclass(self.config_class, BaseConfig)
return suitable_model and super()._check_candidate_backend()

def update_user(self, user: AbstractUser, claims: JSONObject):
Expand Down
104 changes: 104 additions & 0 deletions digid_eherkenning/oidc/claims.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import logging

from glom import Path, PathAccessError, glom
from mozilla_django_oidc_db.typing import ClaimPath, JSONObject

from .models import BaseConfig

logger = logging.getLogger(__name__)


class NoLOAClaim(Exception):
pass


def process_claims(
claims: JSONObject,
config: BaseConfig,
strict: bool = True,
) -> JSONObject:
"""
Given the raw claims, process them using the provided config.
Claim processing performs the following steps:
* Claim name normalization, the provided config model field names are used as keys
* Extracting required and optional values. An error is thrown for missing required
claims, unless a default value is specified in the config.
* Claim value post-processing - if values need to be translated/normalized, the
provided configuration is used.
The return value SHOULD include the ``loa_claim`` key, but if no value is available
(not in the claims and no default specified -> then it's omitted), the key will be
absent.
:arg claims: The raw claims as received from the Identity Provider.
:arg config: The OIDC Configuration instance that specifies which claims should be
extracted and processed.
:arg strict: In strict mode, absent claims that are required (according) to the
configuration raise an error. In non-strict mode, these claims are simply skipped
and omitted.
:returns: A (JSON-serializable) dictionary where the keys are the claim config
field names, taken from ``config.CLAIMS_CONFIGURATION``, and the values their
extracted values from the raw claims. Extracted values have been post-processed
if post-processing configuration was available.
"""
processed_claims = {}

# first, extract all the configured required claims
for claim_config in config.CLAIMS_CONFIGURATION:
field_name = claim_config["field"]
path_bits: ClaimPath = getattr(config, field_name)
try:
value = glom(claims, Path(*path_bits))
except PathAccessError as exc:
if not claim_config["required"]:
continue
# in non-strict mode, do not raise but instead omit the claim. Up to the
# caller to handle missing claims.
if not strict:
continue
claim_repr = " > ".join(path_bits)
raise ValueError(f"Required claim '{claim_repr}' not found") from exc

processed_claims[field_name] = value

# then, loa is hardcoded in the base model, process those...
try:
loa = _process_loa(claims, config)
except NoLOAClaim as exc:
logger.info(
"Missing LoA claim, excluding it from processed claims", exc_info=exc
)
else:
processed_claims["loa_claim"] = loa

return processed_claims


def _process_loa(claims: JSONObject, config: BaseConfig) -> str:
default = config.default_loa
if not (loa_claim := config.loa_claim) and not default:
raise NoLOAClaim("No LoA claim or default LoA configured")

if not loa_claim:
return default

try:
loa = glom(claims, Path(*config.loa_claim))
loa_claim_missing = False
except PathAccessError:
# default could be empty (string)!
loa = default
loa_claim_missing = not default

if loa_claim_missing:
raise NoLOAClaim("LoA claim is absent and no default LoA configured")

# 'from' is string or number, which are valid keys
loa_map: dict[str | float | int, str] = {
mapping["from"]: mapping["to"] for mapping in config.loa_value_mapping
}

# apply mapping, if not found -> use the literal original value instead
return loa_map.get(loa, loa)
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Generated by Django 4.2.13 on 2024-06-21 10:17

from django.db import migrations, models

import mozilla_django_oidc_db.fields


class Migration(migrations.Migration):

dependencies = [
(
"digid_eherkenning_oidc_generics",
"0006_alter_digidconfig_oidc_rp_scopes_list_and_more",
),
]

operations = [
migrations.AddField(
model_name="digidconfig",
name="default_loa",
field=models.CharField(
blank=True,
choices=[
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
"DigiD Basis",
),
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract",
"DigiD Midden",
),
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard",
"DigiD Substantieel",
),
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI",
"DigiD Hoog",
),
],
help_text="Fallback level of assurance, in case no claim value could be extracted.",
max_length=100,
verbose_name="default LOA",
),
),
migrations.AddField(
model_name="digidconfig",
name="loa_claim",
field=mozilla_django_oidc_db.fields.ClaimField(
base_field=models.CharField(
max_length=50, verbose_name="claim path segment"
),
blank=True,
default=None,
help_text="Name of the claim holding the level of assurance. If left empty, it is assumed there is no LOA claim and the configured callback value will be used.",
null=True,
size=None,
verbose_name="LoA claim",
),
),
migrations.AddField(
model_name="digidmachtigenconfig",
name="default_loa",
field=models.CharField(
blank=True,
choices=[
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
"DigiD Basis",
),
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract",
"DigiD Midden",
),
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard",
"DigiD Substantieel",
),
(
"urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI",
"DigiD Hoog",
),
],
help_text="Fallback level of assurance, in case no claim value could be extracted.",
max_length=100,
verbose_name="default LOA",
),
),
migrations.AddField(
model_name="digidmachtigenconfig",
name="loa_claim",
field=mozilla_django_oidc_db.fields.ClaimField(
base_field=models.CharField(
max_length=50, verbose_name="claim path segment"
),
blank=True,
default=None,
help_text="Name of the claim holding the level of assurance. If left empty, it is assumed there is no LOA claim and the configured callback value will be used.",
null=True,
size=None,
verbose_name="LoA claim",
),
),
migrations.AddField(
model_name="eherkenningbewindvoeringconfig",
name="default_loa",
field=models.CharField(
blank=True,
choices=[
("urn:etoegang:core:assurance-class:loa1", "Non existent (1)"),
("urn:etoegang:core:assurance-class:loa2", "Low (2)"),
("urn:etoegang:core:assurance-class:loa2plus", "Low (2+)"),
("urn:etoegang:core:assurance-class:loa3", "Substantial (3)"),
("urn:etoegang:core:assurance-class:loa4", "High (4)"),
],
help_text="Fallback level of assurance, in case no claim value could be extracted.",
max_length=100,
verbose_name="default LOA",
),
),
migrations.AddField(
model_name="eherkenningbewindvoeringconfig",
name="loa_claim",
field=mozilla_django_oidc_db.fields.ClaimField(
base_field=models.CharField(
max_length=50, verbose_name="claim path segment"
),
blank=True,
default=None,
help_text="Name of the claim holding the level of assurance. If left empty, it is assumed there is no LOA claim and the configured callback value will be used.",
null=True,
size=None,
verbose_name="LoA claim",
),
),
migrations.AddField(
model_name="eherkenningconfig",
name="default_loa",
field=models.CharField(
blank=True,
choices=[
("urn:etoegang:core:assurance-class:loa1", "Non existent (1)"),
("urn:etoegang:core:assurance-class:loa2", "Low (2)"),
("urn:etoegang:core:assurance-class:loa2plus", "Low (2+)"),
("urn:etoegang:core:assurance-class:loa3", "Substantial (3)"),
("urn:etoegang:core:assurance-class:loa4", "High (4)"),
],
help_text="Fallback level of assurance, in case no claim value could be extracted.",
max_length=100,
verbose_name="default LOA",
),
),
migrations.AddField(
model_name="eherkenningconfig",
name="loa_claim",
field=mozilla_django_oidc_db.fields.ClaimField(
base_field=models.CharField(
max_length=50, verbose_name="claim path segment"
),
blank=True,
default=None,
help_text="Name of the claim holding the level of assurance. If left empty, it is assumed there is no LOA claim and the configured callback value will be used.",
null=True,
size=None,
verbose_name="LoA claim",
),
),
]
Loading

0 comments on commit a0c9a84

Please sign in to comment.