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

Port OpenID Connect generics for DigiD/eHerkenning #69

Merged
merged 25 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
332f73b
:tada: Copy over digid-eherkenning-oidc-generics code from Open Forms
sergei-maertens May 24, 2024
6f666c1
:pencil: Document digid-eherkenning[oidc]
sergei-maertens May 24, 2024
5c607f1
:alien: GET logout is no longer allowed in modern Django
sergei-maertens May 24, 2024
38af28c
:fire: Drop obsoleted tests
sergei-maertens May 24, 2024
5a761af
:sparkles: Specify default_auto_field in backwards compatible manner
sergei-maertens May 24, 2024
a296a83
:card_file_box: Rename models for better representation of content
sergei-maertens May 24, 2024
0bedd5c
:truck: Split models into separate modules
sergei-maertens May 28, 2024
a914579
:hammer: Parametrize settings for OIDC enabled/disabled
sergei-maertens May 28, 2024
7f257ad
:construction_worker: Hook up oidc-enabled CI build
sergei-maertens May 28, 2024
cbdae9d
:construction_worker: Update to codecov action v4
sergei-maertens May 28, 2024
fa355ac
:construction_worker: Track coverage with flags
sergei-maertens May 28, 2024
b8aec30
:fire: do_op_logout is present in mozilla-django-oidc-db already
sergei-maertens May 28, 2024
93aaa21
:wrench: Update coverage config
sergei-maertens May 28, 2024
187359e
:hammer: Auto-skip tests if OIDC is not enabled
sergei-maertens May 28, 2024
40674ed
:white_check_mark: Add tests for digid/eherkenning flows
sergei-maertens May 28, 2024
f3e55d2
:white_check_mark: Implement base backend and test coverage
sergei-maertens May 28, 2024
623a7e6
:sparkles: Make callback view configurable without having to override…
sergei-maertens May 28, 2024
662de12
:construction_worker: Fix test collection
sergei-maertens May 28, 2024
0476eab
:sparkles: Export base config model
sergei-maertens Jun 3, 2024
9012e74
:ok_hand: PR feedback
sergei-maertens Jun 3, 2024
fed7925
:truck: Move fixtures into conftest of subpackage
sergei-maertens Jun 10, 2024
905b609
:white_check_mark: Add tests for the oidc-subpackage views
sergei-maertens Jun 10, 2024
200d987
:fire: Delete compatibility shim
sergei-maertens Jun 10, 2024
78ac69e
:ok_hand: Remove mapping/required field overrides
sergei-maertens Jun 11, 2024
c254231
:ok_hand: Fix typo's and improve docstrings
sergei-maertens Jun 11, 2024
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
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
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.
"""
136 changes: 136 additions & 0 deletions digid_eherkenning/oidc/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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.constants import OIDC_MAPPING
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."

# update the mapping of discovery endpoint keys to model fields, since our base
# model adds the ``oidc_op_logout_endpoint`` field.
Form.oidc_mapping = {
**deepcopy(OIDC_MAPPING),
"oidc_op_logout_endpoint": "end_session_endpoint",
}
Form.required_endpoints = [
*Form.required_endpoints,
"oidc_op_logout_endpoint",
]
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