Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support LOA claims and claim processing #72

Merged
merged 11 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the types of response we get from IdPs, but is the reason we don't just do the following here:

if loa:
   ...

Because the loa retrieved from the claims can be "" and that is valid?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signicat appears to only return the BSN and no LOA information, Anoigo returns strings like "20" but I'm being very defensive here since other providers may do even other things or intermediate keycloaks can rewrite claims...

Basically we check for us if a default is set to fall back: we consider the claim missing if:

  • no claim to look up is configured and the default is empty
  • the claim is configured, but not present and the default is empty

in other cases we can return a value:

  • a (non-empty) default configured but no claim lookup configured -> use the default
  • a claim lookup configured and the claim is found -> use the value, possibly it's post-processed with the value map. That should allow to map an empty string or 0 or whatever to a value that makes sense

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