Skip to content

Commit

Permalink
Merge pull request #69 from maykinmedia/feature/oidc-generics
Browse files Browse the repository at this point in the history
Port OpenID Connect generics for DigiD/eHerkenning
  • Loading branch information
sergei-maertens authored Jun 11, 2024
2 parents 7d31dbb + c254231 commit 0f75dbd
Show file tree
Hide file tree
Showing 31 changed files with 1,991 additions and 20 deletions.
24 changes: 21 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,23 @@ on:

jobs:
tests:
name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }})
name: "Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}, OIDC: ${{ matrix.oidc_enabled }})"
runs-on: ubuntu-latest
strategy:
matrix:
python: ['3.10', '3.11', '3.12']
django: ['4.2']
oidc_enabled: ['no', 'yes']

services:
postgres:
image: postgres:15
env:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

steps:
- uses: actions/checkout@v4
Expand All @@ -34,13 +45,20 @@ jobs:
run: pip install tox tox-gh-actions

- name: Run tests
run: tox
run: |
tox -- ${{ matrix.oidc_enabled != 'yes' && '--ignore tests/oidc' || '' }}
env:
PYTHON_VERSION: ${{ matrix.python }}
DJANGO: ${{ matrix.django }}
OIDC_ENABLED: ${{ matrix.oidc_enabled }}
PGUSER: postgres
PGHOST: localhost

- name: Publish coverage report
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: ${{ matrix.oidc_enabled == 'yes' && 'oidc' || 'base' }}

publish:
name: Publish package to PyPI
Expand Down
18 changes: 18 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
flag_management:
default_rules:
carryforward: true
statuses:
- type: project
target: auto
threshold: 1%
- type: patch
target: 90%
individual_flags: # exceptions to the default rules above, stated flag by flag
- name: base
paths:
- digid_eherkenning
- '!digid_eherkenning/oidc/'
- name: oidc
paths:
- digid_eherkenning/oidc/
carryforward: true
8 changes: 0 additions & 8 deletions digid_eherkenning/compat.py

This file was deleted.

65 changes: 65 additions & 0 deletions digid_eherkenning/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
DigiD-eHerkenning-OIDC-generics abstracts authenticating over OIDC.
DigiD/eHerkenning are typically exposed via SAML bindings, but there exist identity
providers that abstract this and instead offer an OpenID Connect flow to log in with
DigiD and/or eHerkenning. This package facilitates integrating with such providers.
The architecture and authentication flows are tricky in some places. Here's an attempt
to explain it.
**Configuration**
Each authentication means (DigiD, eHerkenning, mandate (machtigen) variants...) is
mapped to an OpenID client configuration, which roughly holds:
- the OIDC endpoints to use/redirect users to
- the OAUTH2 client ID and secret to use, which indicate to the IdP which authentication
means they should send the user to
- which claims to look up/extract from the UserInfo endpoint/JWT
These are stored in (subclasses of) the
:class:`~digid_herkenning.oidc.models.OpenIDConnectBaseConfig` model.
**Authentication flow**
When a user starts a login flow, they:
1. Click the appriopriate button/link
2. A Django view processes this and looks up the relevant configuration
3. The view redirects the user to the identity provider (typically a different domain)
4. Authenticate with the IdP
5. The IdP redirects back to our application
6. Our callback view performs the OIDC exchange and extracts + stores the relevant user
information
7. Finally, the callback view looks up where the user needs to be redirected to and
sends them that way.
Steps 2-3 are called the "init" phase in this package, while steps 6-7 are the
"callback" phase.
**Init phase**
The mozilla-django-oidc-db package provides the
:class:`~mozilla_django_oidc_db.views.OIDCInit` view class, for the init phase. It
ensures that the specified config class is persisted in the authentication state.
This package provides concrete views bound to configuration classes:
* :attr:`~digid_herkenning.oidc.views.digid_init`
* :attr:`~digid_herkenning.oidc.views.digid_machtigen_init`
* :attr:`~digid_herkenning.oidc.views.eherkenning_init`
* :attr:`~digid_herkenning.oidc.views.eherkenning_bewindvoering_init`
**Callback phase**
The callback phase validates the code and state, and loads which configuration class
needs to be used from the state. With this information, the authentication backends
from ``settings.AUTHENTICATION_BACKENDS`` are tried in order. Typically this will
use the backend shipped in mozilla-django-oidc-db, or a subclass of it.
The OpenID connect flow exchanges the code for an access token (and ID token), and
the user details are retrieved. You should provide a customized backend to determine
what needs to be done with this user information, e.g. create a django user or store
the information in the django session.
"""
123 changes: 123 additions & 0 deletions digid_eherkenning/oidc/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from collections.abc import Sequence
from copy import deepcopy

from django.contrib import admin
from django.forms import modelform_factory
from django.utils.translation import gettext_lazy as _

from mozilla_django_oidc_db.forms import OpenIDConnectConfigForm
from solo.admin import SingletonModelAdmin

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

# Using a dict because these retain ordering, and it makes things a bit more readable.
ATTRIBUTES_MAPPING_TITLE = _("Attributes to extract from claim")
COMMON_FIELDSETS = {
_("Activation"): {
"fields": ("enabled",),
},
_("Common settings"): {
"fields": (
"oidc_rp_client_id",
"oidc_rp_client_secret",
"oidc_rp_scopes_list",
"oidc_rp_sign_algo",
"oidc_rp_idp_sign_key",
),
},
ATTRIBUTES_MAPPING_TITLE: {
"fields": (), # populated from the factory function below
},
_("Endpoints"): {
"fields": (
"oidc_op_discovery_endpoint",
"oidc_op_jwks_endpoint",
"oidc_op_authorization_endpoint",
"oidc_op_token_endpoint",
"oidc_token_use_basic_auth",
"oidc_op_user_endpoint",
"oidc_op_logout_endpoint",
),
},
_("Keycloak specific settings"): {
"fields": ("oidc_keycloak_idp_hint",),
"classes": ["collapse in"],
},
_("Advanced settings"): {
"fields": (
"oidc_use_nonce",
"oidc_nonce_size",
"oidc_state_size",
"oidc_exempt_urls",
"userinfo_claims_source",
),
"classes": ["collapse in"],
},
}


def admin_modelform_factory(model: type[OpenIDConnectBaseConfig], *args, **kwargs):
"""
Factory function to generate a model form class for a given configuration model.
The configuration model is expected to be a subclass of
:class:`~digid_eherkenning_oidc_generics.models.OpenIDConnectBaseConfig`.
Additional args and kwargs are forwarded to django's
:func:`django.forms.modelform_factory`.
"""
kwargs.setdefault("form", OpenIDConnectConfigForm)
Form = modelform_factory(model, *args, **kwargs)
assert issubclass(
Form, OpenIDConnectConfigForm
), "The base form class must be a subclass of OpenIDConnectConfigForm."
return Form


def fieldsets_factory(claim_mapping_fields: Sequence[str]):
"""
Apply the shared fieldsets configuration with the model-specific overrides.
"""
_fieldsets = deepcopy(COMMON_FIELDSETS)
_fieldsets[ATTRIBUTES_MAPPING_TITLE]["fields"] += tuple(claim_mapping_fields)
return tuple((label, config) for label, config in _fieldsets.items())


@admin.register(DigiDConfig)
class OpenIDConnectConfigDigiDAdmin(SingletonModelAdmin):
form = admin_modelform_factory(DigiDConfig)
fieldsets = fieldsets_factory(claim_mapping_fields=["identifier_claim_name"])


@admin.register(EHerkenningConfig)
class OpenIDConnectConfigEHerkenningAdmin(SingletonModelAdmin):
form = admin_modelform_factory(EHerkenningConfig)
fieldsets = fieldsets_factory(claim_mapping_fields=["identifier_claim_name"])


@admin.register(DigiDMachtigenConfig)
class OpenIDConnectConfigDigiDMachtigenAdmin(SingletonModelAdmin):
form = admin_modelform_factory(DigiDMachtigenConfig)
fieldsets = fieldsets_factory(
claim_mapping_fields=[
"vertegenwoordigde_claim_name",
"gemachtigde_claim_name",
]
)


@admin.register(EHerkenningBewindvoeringConfig)
class OpenIDConnectConfigEHerkenningBewindvoeringAdmin(SingletonModelAdmin):
form = admin_modelform_factory(EHerkenningBewindvoeringConfig)
fieldsets = fieldsets_factory(
claim_mapping_fields=[
"vertegenwoordigde_company_claim_name",
"gemachtigde_person_claim_name",
]
)
10 changes: 10 additions & 0 deletions digid_eherkenning/oidc/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _


class OIDCAppConfig(AppConfig):
name = "digid_eherkenning.oidc"
verbose_name = _("DigiD & eHerkenning via OpenID Connect")
# can't change this label because of existing migrations in Open Forms/Open Inwoner
label = "digid_eherkenning_oidc_generics"
default_auto_field = "django.db.models.AutoField"
16 changes: 16 additions & 0 deletions digid_eherkenning/oidc/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.contrib.auth.models import AbstractUser

from mozilla_django_oidc_db.backends import OIDCAuthenticationBackend
from mozilla_django_oidc_db.typing import JSONObject

from .models.base import OpenIDConnectBaseConfig


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

def update_user(self, user: AbstractUser, claims: JSONObject):
# do nothing by default
return user
Loading

0 comments on commit 0f75dbd

Please sign in to comment.