Skip to content

Proposal: OIDC authentication hook in EngineBlock #2093

Description

@mroest

Status: draft, open for comment · 14 September 2026
Raised on behalf of the govroam Foundation, operator of the govConext federation.
For review by the OpenConext maintainers.


1. Summary

This proposal adds an optional OIDC authentication hook to EngineBlock. With the hook enabled,
EngineBlock interrupts the SAML flow after the IdP login, runs an OIDC authorization-code cycle
against an external OIDC provider, and merges the returned claims into the assertion delivered to the
SP. DEZI, the Dutch healthcare identity scheme, is the first and currently only configured instance.

Four characteristics shape the change:

  • The hook is provider-agnostic. The filter, the processing step, the redirect, the callback route
    and the OIDC client are named and written for OIDC. DEZI appears in two places only: the meaning of
    the claims, and the configuration of the single instance.
  • Changes to existing files are additive. No existing method changes behavior.
  • The implementation follows the interrupt/resume pattern SRAM already uses.
  • The hook is off by default behind a feature flag. With the flag off, the addition to a login is a
    single comparison in the input filter chain.

All OIDC and JWT handling sits behind one interface and the claim semantics behind a second, so the
rest of EngineBlock stays free of both OIDC and DEZI concepts and either layer can be extracted
later.


2. Background

DEZI ("De Zorg Identiteit") is the Dutch healthcare identity scheme. Dutch legislation requires
healthcare applications to be accessed only with a verified DEZI identity at eIDAS level high
(LoA 3). The "Ontkoppelpunt" is DEZI's OIDC provider, operated by the CIBG; it brokers the
authentication-method suppliers and returns a profile of three claims: DEZI ID, role code (BIG) and
organization code.

That profile does not carry the additional attributes healthcare applications need — regional codes,
group membership and similar. Those come from the institutional IdP through govConext.

Without a central connection, every SP in the federation would have to build its own OIDC client to
DEZI. That duplicates effort across SPs, spreads healthcare-authentication logic over many codebases,
and adds components to the critical authentication path. A central connection means every SP behind
the federation complies and receives the claims without connecting to DEZI itself, and without extra
user interaction inside OpenConext beyond DEZI's own screens.

EngineBlock today brokers the SAML flow, applies the attribute release policy and consent, and can
step up to the Stepup Gateway. Two gaps remain:

  • The step-up mechanism redirects to the Stepup Gateway over SAML. It cannot drive an OIDC
    provider.
  • A step-up result is a level-of-assurance assertion. It does not carry or merge arbitrary claims
    into the outgoing assertion.

3. Proposed change

An OIDC hook in EngineBlock, switched on with a feature flag and configured against one OIDC
provider. With the flag on, EngineBlock interrupts the flow after the IdP login and before consent,
sends the user to the provider, retrieves the claims there, and adds them to the attributes the IdP
already released. The SP receives a single assertion carrying both sets.

Technically this is one extra browser redirect plus one back-channel call. EngineBlock becomes an
OIDC client to the provider while remaining a SAML IdP to the SP.

Where DEZI appears, and where it does not

The hook divides into two layers along one line: the transport is generic, the meaning of the claims
is not.

The generic layer runs the authorization-code cycle with PKCE, authenticates at the token endpoint,
validates the token and hands over a claim set. It holds no knowledge of DEZI, and any OIDC provider
a federation wants to consult mid-flow fits behind it. Everything this layer adds to existing
EngineBlock files carries an OIDC name.

The DEZI layer reads that claim set: it opens the identity statement, takes out the three claims and
maps them onto SAML attributes under the URNs govroam agrees with the SPs. That layer is the only
place where DEZI is a concept in code, and it lives in its own namespace.

The configuration follows the same line. One feature flag switches the mechanism on; one dezi.*
parameter block configures the single instance — endpoints, client credentials, keys and the
attribute mapping. A second provider means a second block, not a second mechanism.

Two interfaces carry the whole of it. OidcAuthenticationInterface hides the OIDC library and the
protocol; OidcClaimMergerInterface hides what the claims mean. Outside the two namespaces,
EngineBlock sees those interfaces and their value objects and nothing else.


4. The flow end to end

Steps 1 to 4 and step 9 work today. Steps 5 to 8 are new.

1–4. Regular SAML login. The SP sends the user to the WAYF, EngineBlock forwards to the
institutional IdP, the user logs in, and the IdP returns an assertion carrying the attributes.

5. Interrupt. The input filter chain runs as always: attribute normalization, user provisioning,
attribute aggregation. OidcInterruptFilter sits directly after SramInterruptFilter and checks
whether the flag is on. If so, it asks the OIDC component for an authorization request. That returns
a URL, a state, a nonce and a code_verifier for PKCE. The filter sets those four on the response
and does nothing else.

The remaining filters — EnforcePolicy and AttributeReleasePolicy — still run in this pass, so the
IdP attributes are already filtered before the redirect. AssertionConsumer then finds the marker set,
stores the full SAML state under STEP_OIDC and lets ProxyServer redirect the browser to DEZI.

6–7. The user authenticates at DEZI. DEZI shows its own selection screen, forwards to the
authentication-method supplier and receives the user back. That part belongs to DEZI; EngineBlock sees
nothing of it.

8. Return. DEZI sends the browser to /authentication/idp/oidc-callback with a code and the
state. The service module extracts the SAML request ID from the state, uses it to look up the
stored step, and compares the received state byte for byte against the stored one. On a match the module
exchanges the code at DEZI, validates the token and reads the claims.

The DEZI layer takes the three claims out of that claim set, the mapper converts them into SAML
attributes, and the service module adds them to the attributes already present and hands the flow
back to consent.

9. On to the SP. Nothing changes from here. Consent runs, the output filters run, and the SP
receives a single assertion carrying both attribute sets.

On failure

On every failure — a mismatched state, a canceled login, a token that does not validate, a DEZI
server that does not respond — the component raises a single exception. The service module translates it
into EngineBlock_Exception_OidcAuthenticationFailed, exactly as SramInterruptFilter does with
SbsCheckFailed. FallbackExceptionListener catches any EngineBlock_Exception and redirects to
feedback_unknown_error, which is where SbsCheckFailed lands today. A page naming DEZI as the cause
requires a branch in RedirectToFeedbackPageExceptionListener, a feedback route and a template; see
§13. The SAML flow is not completed: nothing is merged and no partial assertion reaches the SP.


5. Following the SRAM interrupt/resume pattern

EngineBlock already uses this pattern. SRAM interrupts the flow in the same way: a filter sets a
marker, ProxyServer redirects the browser, and a service module resumes the flow on return. The
OIDC hook is placed alongside SRAM, in the same places, with the system name in each identifier
replaced by the protocol name.

The two flows are not identical. SRAM makes a back-channel call to SBS and merges its answer without
leaving EngineBlock. The OIDC hook sends the browser to the provider first and makes its back-channel
call only after the user returns. The interrupt/resume machinery around both is the same.

Concern SRAM today OIDC hook
Filter in the chain SramInterruptFilter OidcInterruptFilter
Marker on the response SramInterruptNonce OidcState, OidcNonce, OidcCodeVerifier, OidcAuthorizationUrl
Processing step STEP_SRAM STEP_OIDC
Callout needed? shouldPerformSramCallout() shouldPerformOidcCallout()
Perform the callout handleSramInterruptCallout() handleOidcCallout()
Store the step addSramStep() addOidcStep()
Return route /authentication/idp/process-sraminterrupt /authentication/idp/oidc-callback
Service module EngineBlock_Corto_Module_Service_SramInterrupt EngineBlock_Corto_Module_Service_OidcCallback
Adapter method processSramInterrupt() processOidcCallback()
Mock provider SbsController in the functional testing bundle DeziOidcController in the functional testing bundle

The mock provider is the one entry that keeps the DEZI name: it serves DEZI's claims and exists to
test against them.


6. Impact on the codebase

Existing files that change

File Change Size
library/EngineBlock/Saml2/ResponseAnnotationDecorator.php Four fields with empty defaults, plus getters and setters, plus four lines in __serialize() ~30 lines
src/OpenConext/EngineBlock/Service/ProcessingStateHelperInterface.php One constant: STEP_OIDC = 'oidc' 1 line
library/EngineBlock/Corto/ProxyServer.php Three methods (shouldPerformOidcCallout, handleOidcCallout, addOidcStep) and two lines in the route maps ~35 lines
library/EngineBlock/Corto/Module/Service/AssertionConsumer.php Two if blocks alongside the existing SRAM checks ~8 lines
library/EngineBlock/Corto/Module/Service/StepupAssertionConsumer.php The same check after returning from Stepup ~4 lines
library/EngineBlock/Corto/Filter/Input.php One filter added to the chain ~6 lines
library/EngineBlock/Corto/Module/Services.php One case in the factory ~6 lines
library/EngineBlock/Corto/Adapter.php One method: processOidcCallback() 4 lines
library/EngineBlock/Application/DiContainer.php Three getters ~15 lines
src/OpenConext/EngineBlockBundle/Controller/IdentityProviderController.php One route action ~10 lines
config/packages/engineblock_features.yaml One flag 1 line
config/packages/parameters.yml.dist One dezi.* block ~12 lines
config/services/services.yml Four service definitions ~30 lines
composer.json One OIDC/JWT library, see §9 1 line

No existing method changes behavior; everything is added alongside.

New files

Location Contents
src/OpenConext/EngineBlockBundle/Oidc/ The two interfaces, the value objects, the OIDC client, the configuration object and the exception
src/OpenConext/EngineBlockBundle/Dezi/ The profile, the profile reader, the attribute mapper and the claim merger
library/EngineBlock/Corto/Filter/Command/OidcInterruptFilter.php The filter that interrupts the flow
library/EngineBlock/Corto/Module/Service/OidcCallback.php The service module that resumes it
library/EngineBlock/Exception/OidcAuthenticationFailed.php The exception that triggers the feedback page, alongside SbsCheckFailed.php
src/OpenConext/EngineBlockFunctionalTestingBundle/ The mock DEZI provider for the tests
tests/ Unit tests and one Behat feature

DEZI appears in three of these places: the Dezi/ namespace, the mock provider in the functional
testing bundle, and the dezi.* block in parameters.yml.dist. Every other changed and added file
carries an OIDC name.

What stays untouched

  • Assertion writing. Attributes are added before the output filters run. The code that builds and
    signs the SAML response is unchanged.
  • Consent. It runs after DEZI and simply sees a larger attribute set.
  • Stepup and SRAM. The new checks sit alongside; nothing is replaced.
  • Manage, the policy decision point and metadata. No changes. The trigger is a flag.
  • Session storage itself. Four fields are added; the serialization already tolerates that.
  • Existing routes. One is added.
  • Single logout. DEZI advertises no end_session_endpoint and no front- or back-channel logout, so
    there is nothing to propagate. A SAML logout at EngineBlock ends the EngineBlock session as it does
    today.
  • Behavior with the flag off. The only addition is a single comparison in the filter chain.

7. Components

7.1 Where the code lives

Two namespaces, next to Sbs/, where the pattern being followed also lives.

src/OpenConext/EngineBlockBundle/Oidc/ holds the generic layer: the two interfaces, the value
objects, the OIDC client, the configuration object and the exception. Nothing in it names DEZI.

src/OpenConext/EngineBlockBundle/Dezi/ holds the claim semantics: the profile, the reader that
takes the profile out of a claim set, the mapper that turns it into SAML attributes, and the merger
that joins the two behind the generic interface. It depends on the Oidc/ namespace; the dependency
does not run the other way. Swapping DEZI for another provider means a second namespace beside it and
a changed service definition, with nothing in library/ touched.

EngineBlock carries no other OIDC concept today — §9 records that it holds no OIDC or JWT library at
all — so the Oidc prefix is unambiguous within this codebase. EngineBlock is an OIDC client here and
never an OIDC provider; the OpenConext OIDC provider is a separate component.

The filter and the service module have to sit in library/, because that is where the Corto chain
lives. That is the only reason for their location.

7.2 The generic interface and its value objects

This is the only way the rest of EngineBlock talks to an OIDC provider. It returns claims, not a
profile: the interface does not know what the claims mean.

interface OidcAuthenticationInterface
{
    /** Builds the authorization request. $loginHint is null when no hint is known. */
    public function startAuthentication(string $requestId, ?string $loginHint): OidcAuthenticationRequest;

    /** Exchanges the code, validates everything and returns the validated claims.
     *  Throws OidcAuthenticationException on any failure. */
    public function handleCallback(
        array $callbackParameters,
        string $expectedState,
        string $expectedNonce,
        string $codeVerifier,
    ): OidcClaimSet;
}

With two value objects alongside it:

final readonly class OidcAuthenticationRequest
{
    public function __construct(
        public string $authorizationUrl,  // complete, including state, nonce and PKCE
        public string $state,
        public string $nonce,
        public string $codeVerifier,
    ) {}
}

final readonly class OidcClaimSet
{
    /** @param array<string, mixed> $claims — validated, merged from the token and userinfo */
    public function __construct(public array $claims) {}

    public function has(string $claim): bool;
    public function string(string $claim): ?string;
}

And a configuration object that carries the transport half of the dezi.* parameters from §8 to the
OIDC client:

final readonly class OidcConfiguration
{
    public function __construct(
        public string  $issuer,
        public string  $clientId,
        public string  $privateKeyPath,
        public string  $redirectUri,
        public array   $scopes,
        public ?string $acrValues,
        public ?string $loginHintAttribute,
        public bool    $verifyPeer,
    ) {}
}

A second, smaller interface keeps the service module free of claim semantics. SRAM has the same
seam in SbsAttributeMerger.

interface OidcClaimMergerInterface
{
    /** Turns a validated claim set into the SAML attributes to merge into the assertion.
     *  Throws OidcAuthenticationException when the claims do not carry what it needs. */
    public function toSamlAttributes(OidcClaimSet $claims): array;
}

One exception type: OidcAuthenticationException. A single type for every failure, because the flow
treats them the same — stop and render the feedback page.

This keeps the rest of EngineBlock testable without OIDC. The filter tests and the tests on the resume
run against a fake implementation of this interface.

7.3 The OIDC client

OidcClient is the only class that touches the OIDC library.

The authorization request carries client_id, redirect_uri, response_type=code,
scope=openid, a fresh state, a fresh nonce, a PKCE code_challenge using S256, and
acr_values when configured. When a login_hint source attribute is configured and present, it is
included.

The state is a random value with the SAML request ID embedded, so the resume can find the stored
step. That embedding is not trusted: the full state is compared against the stored value on return.

The return does four things, in this order:

  1. Compare state against the stored value. On a mismatch the flow stops immediately.
  2. Check whether the provider returned an error. If so, stop.
  3. Exchange the code at the token endpoint, using private_key_jwt and the code_verifier.
  4. Validate the token: signature against the provider's JWKS, iss, aud, exp, and nonce
    against the stored value. Decrypt it first when it arrives as a JWE.

The client then merges the claims from the token with those from userinfo and returns them as an
OidcClaimSet. It does not inspect their meaning. Where the DEZI profile arrives — in the token,
through the claims parameter or via userinfo — is defined in DEZI's interface specification and is
currently the last real gap. See §11. Reading both sources and merging keeps that question inside
configuration rather than inside code.

Signature validation, JWE decryption and JWKS caching are the client's work because OIDC defines
them. The eSeal DEZI puts on the identity statement is not part of OIDC and belongs to §7.4.

What does not happen: no token, raw payload or key is ever logged. The client logs that it starts,
that it finishes, and which type of failure occurred.

7.4 The DEZI claim semantics

The Dezi/ namespace turns a claim set into SAML attributes. It is the only code in EngineBlock that
knows what a DEZI claim means.

final readonly class DeziProfile
{
    public function __construct(
        public string $deziId,
        public string $roleCode,
        public string $organizationCode,
    ) {}
}

final class DeziProfileReader
{
    /** Opens the identity statement, verifies the eSeal and reads the three claims.
     *  Throws OidcAuthenticationException when the statement is absent or does not verify. */
    public function read(OidcClaimSet $claims): DeziProfile;
}

DeziProfileReader does the part only DEZI requires: unwrapping the sealed identity statement and
knowing which claim carries which of the three values. Which key and which algorithm the eSeal uses
follows from DEZI's interface specification.

DeziAttributeMapper is a pure transformation without network traffic: a profile plus a configurable
attribute map in, an array of SAML attributes out.

public function toSamlAttributes(DeziProfile $profile): array;
// returns ['urn:...:dezi-id' => ['123'], 'urn:...:role-code' => ['...'], ...]

Empty values are omitted rather than released as empty attributes.

DeziAttributeConfiguration carries the map from dezi.attribute_mapping and fills in missing names
with the defaults from code, so an empty or incomplete mapping yields the defaults rather than an
empty attribute. Which URNs these become is an agreement between govroam and the SPs. The current
values are a working placeholder, to be replaced once that agreement exists.

DeziClaimMerger implements OidcClaimMergerInterface and composes the two: it hands the claim set
to the reader and the resulting profile to the mapper. That implementation is what the service module
receives, so the module never sees a DeziProfile.

7.5 The interrupt filter

OidcInterruptFilter sits in the input filter chain, directly after SramInterruptFilter. It does
exactly three things:

public function execute(): void
{
    if (!$this->featureConfiguration->isEnabled('eb.feature_enable_oidc_hook')) {
        return;
    }

    $loginHint = $this->findLoginHint($this->_responseAttributes);

    $authRequest = $this->oidcAuthentication->startAuthentication(
        $this->_request->getId(),
        $loginHint,
    );

    $this->_response->setOidcAuthorizationUrl($authRequest->authorizationUrl);
    $this->_response->setOidcState($authRequest->state);
    $this->_response->setOidcNonce($authRequest->nonce);
    $this->_response->setOidcCodeVerifier($authRequest->codeVerifier);
}

With the flag off this is a single comparison and nothing further. That is the entire overhead for
installations that do not use the hook.

The filter holds no DEZI concept. It reads the flag, asks the OIDC component for an authorization
request and writes four values onto the response. Which provider that request points at follows from
configuration.

The filter does not redirect. ProxyServer does, as it does for SRAM.

One point to note: the step must never be skipped because a session already exists. Single
sign-on is not permitted at eIDAS high, so every login has to pass through DEZI again. The filter
therefore looks only at the flag, not at session state.

Production adds the real trigger logic here — per SP, per IdP or on the requested LoA. Its shape is not
settled; see §11. The filter is where that lands, exactly as SRAM uses the service
provider's collabEnabled().

7.6 The callout

ProxyServer gains three methods, modeled on their SRAM counterparts:

public function shouldPerformOidcCallout(ResponseAnnotationDecorator $response): bool
{
    return $response->getOidcAuthorizationUrl() !== '';
}

public function handleOidcCallout(ResponseAnnotationDecorator $response): void
{
    $this->getLogger()->info('Handle OIDC callout');
    $this->redirect($response->getOidcAuthorizationUrl(), '');
}

public function addOidcStep(ResponseAnnotationDecorator $response, AuthnRequestAnnotationDecorator $request): void
{
    $this->_diContainer->getProcessingStateHelper()->addStep(
        $request->getId(),
        ProcessingStateHelperInterface::STEP_OIDC,
        $this->getEngineSpRole(),
        $response,
    );
}

AssertionConsumer gains two blocks alongside the existing SRAM checks:

if ($this->_server->shouldPerformSramCallout($receivedResponse) === true) {
    $this->_server->addSramStep($receivedResponse, $receivedRequest);
}
if ($this->_server->shouldPerformOidcCallout($receivedResponse) === true) {   // new
    $this->_server->addOidcStep($receivedResponse, $receivedRequest);
}

$this->_server->addConsentProcessStep($receivedResponse, $receivedRequest);

// ... existing Stepup and SRAM blocks ...

if ($this->_server->shouldPerformOidcCallout($receivedResponse) === true) {   // new
    $this->_server->handleOidcCallout($receivedResponse);
    return;
}

$this->_server->handleConsentAuthenticationCallout($receivedResponse, $receivedRequest);

The order is Stepup, then SRAM, then the OIDC hook, then consent. No exclusivity is enforced. With
everything enabled they run one after another, which asks three consecutive actions of the user. That
combination falls outside the proof of concept; see §11.

The same check goes into StepupAssertionConsumer, so the hook also runs when the user has just
returned from Stepup.

7.7 The resume

The route is /authentication/idp/oidc-callback, with route name authentication_idp_oidc_callback.
It has to be known in three places: the two route maps at the top of ProxyServer, and an action in
IdentityProviderController that does nothing but construct EngineBlock_Corto_Adapter and call it,
in the same shape as processSramInterrupt.

The OidcCallback service module does the work:

public function serve($serviceName, Request $httpRequest): void
{
    $state = (string) $httpRequest->get('state');
    $id    = $this->extractRequestId($state);

    $step     = $this->processingStateHelper->getStepByRequestId($id, ProcessingStateHelperInterface::STEP_OIDC);
    $response = $step->getResponse();
    $request  = $this->server->getReceivedRequestFromResponse($response);

    try {
        $claims = $this->oidcAuthentication->handleCallback(
            $httpRequest->query->all(),
            $response->getOidcState(),
            $response->getOidcNonce(),
            $response->getOidcCodeVerifier(),
        );
        $merged = $this->claimMerger->toSamlAttributes($claims);
    } catch (OidcAuthenticationException $e) {
        $this->logger->error('OIDC authentication failed: ' . $e->getMessage());
        // The exception listener takes over from here; the flow stops.
        throw new EngineBlock_Exception_OidcAuthenticationFailed($e->getMessage());
    }

    $attributes = $response->getAssertion()->getAttributes();
    $attributes = array_merge($attributes, $merged);

    $response->getAssertion()->setAttributes($attributes);
    $response->getAssertion()->setAttributesValueTypes([]);   // let SAML2 determine them again

    $this->server->addConsentProcessStep($response, $request);
    $this->server->handleConsentAuthenticationCallout($response, $request);
}

This follows the same shape as SramInterrupt::serve(), including resetting the attribute value types.
Omit it and SAML2 keeps the old types, so the new attributes reach the SP incorrectly typed.

The module holds two dependencies, both of them interfaces: OidcAuthenticationInterface and
OidcClaimMergerInterface. Neither names DEZI, and a claim the merger cannot read raises the same
exception as a token that does not validate.

7.8 What the SP receives, and what it does not

The assertion to the SP carries the attributes the IdP released, filtered by the attribute release
policy, plus the three DEZI claims under the configured URNs. Three things that arrive from DEZI
stop at EngineBlock.

From DEZI Reaches the SP
DEZI ID, role code, organization code Yes, as SAML attributes under the configured URNs
Authentication at eIDAS high No. The assertion keeps the authentication context of the institutional IdP
The signed and encrypted identity statement No. EngineBlock opens it and forwards the three claims

The level of assurance. The user authenticates at DEZI at eIDAS high, and the outgoing assertion
keeps the level of the institutional IdP. An SP cannot act on the level and has to derive from the
presence of the DEZI attributes that a strong authentication took place. Raising the level touches
assertion construction; see §11.

The identity statement. DEZI signs the statement with an eSeal and encrypts it. EngineBlock
decrypts it, reads the three claims and discards the envelope, so the SP holds the claims but not the
signed statement and cannot verify the seal itself. Forwarding the statement unchanged is not an
option either: it is encrypted for EngineBlock, and the SP holds no key for it. Whether an SP needs
the statement for NEN 7513 accountability is a question for govroam and GGD GHOR.

Which pseudonym the SP receives. The DEZI subject is pairwise: a different pseudonym per OIDC
client. In the DEZI model each platform supplier is its own client and receives its own pseudonym for
the same person. EngineBlock is one client for the entire federation, so every SP behind it receives
the same pseudonym, and the same person is recognizable across SPs. Whether the federation may hold
one client for all SPs, or whether EngineBlock has to derive a pseudonym per SP, is a question for the
CIBG. Whether the DEZI ID is the subject or a separate claim is open; see §11.

7.9 What goes into the session

Four fields on ResponseAnnotationDecorator, all protected string defaulting to "":

Field Why it has to travel
OidcAuthorizationUrl The URL ProxyServer redirects to
OidcState To validate the return
OidcNonce To validate the token
OidcCodeVerifier To exchange the code (PKCE)

Behavior during a blue/green deployment. The class uses __serialize() and __unserialize(),
and the latter iterates the received keys with a property_exists check. An older version encountering
a new field ignores it; a newer version reading an older record keeps the empty default. Fields are
only added; no existing key changes. A round-trip test covers both directions.

The code_verifier is briefly a secret in the session. That session already holds the full SAML
assertion, so no new kind of sensitive data is introduced, but it is worth knowing.


8. Configuration

The naming split runs through the configuration as well. The flag switches the mechanism on and
carries no provider name. The parameter block configures the one instance and is named after it. A
second provider means a second block and a second service definition, with the flag and the mechanism
unchanged.

One flag and one block of parameters, modeled on sram.*:

# config/packages/parameters.yml.dist
parameters:
    feature_enable_oidc_hook: false      # off by default

    ## DEZI settings
    dezi.issuer:              'https://engine.dev.openconext.local/functional-testing/dezi'
    dezi.client_id:           'engineblock-dezi'
    dezi.private_key_path:    '/etc/openconext/dezi/client.key'
    dezi.redirect_uri:        'https://engine.dev.openconext.local/authentication/idp/oidc-callback'
    dezi.scopes:              'openid'
    dezi.acr_values:          ''         # empty until DEZI confirms the value
    dezi.login_hint_attribute: ''        # empty = no login_hint
    dezi.verify_peer:         false      # as sram.verify_peer; true against DEZI itself
    dezi.attribute_mapping:
        dezi_id:              'urn:mace:dezi.nl:dezi-id'
        role_code:            'urn:mace:dezi.nl:role-code'
        organization_code:    'urn:mace:dezi.nl:organization-code'

engineblock_features.yaml maps the flag onto that parameter as eb.feature_enable_oidc_hook, in
the line below eb.feature_enable_sram_interrupt.

The dezi.* values divide over two objects. OidcConfiguration receives the transport half —
issuer, client_id, private_key_path, redirect_uri, scopes, acr_values,
login_hint_attribute and verify_peer. DeziAttributeConfiguration receives
attribute_mapping. The parameter names stay flat and stay under one prefix, so an operator reads
one block.

There is no client_secret. DEZI's token endpoint offers private_key_jwt and none, so
private_key_jwt is the only authenticated option. A key pair is required, with the public half
registered at DEZI. Where the private key is stored is an operational question rather than
a design one — the key is read from a path in configuration, as EngineBlock does elsewhere.

The attribute names above are a placeholder. See §7.4.

Switching between the mock provider and DEZI itself is a change in this block alone. No if (mock)
appears in production code.

config/services/services.yml gains four definitions, wired as the engineblock.sbs.* block is: an
HTTP client, the OIDC client, the DEZI claim merger and the attribute mapper the merger composes. The
merger is the service bound to OidcClaimMergerInterface; that binding is the single place where the
generic mechanism meets DEZI. DiContainer gains three getters, as it has for SBS.


9. New dependencies

EngineBlock currently carries no OIDC or JWT library. No web-token, no jose, no
league/oauth2, no lcobucci/jwt. This change introduces the first one. That is a genuine extension
of the dependency tree, and that choice belongs with the OpenConext maintainers.

The constraint is sharper than it appears: EngineBlock runs on PHP 8.5 and Symfony 7.4. Not every
library supports that yet.

The candidates:

Library Coverage Point of attention
web-token/jwt-framework Full JWT, JWS and JWE handling Heavy; include only the required parts
facile-it/php-openid-client Complete OIDC client Verify maintenance cadence
league/oauth2-client OAuth2 only Token validation has to be built on top
jumbojett/openid-connect-php Lightweight OIDC client Limited room for private_key_jwt and encrypted payloads

The investigation produces one recommendation with exact versions, the number of transitive
dependencies, the license and the maintenance cadence. That recommendation goes to the OpenConext
maintainers before it lands in composer.json.

Everything sits behind the interface. Replacing the library means rewriting one class; the rest of
EngineBlock is not involved.


10. Risks

Risk Likelihood Mitigation
A change in ProxyServer or AssertionConsumer breaks Stepup, SRAM or consent Real; this is the core of the flow Follow the SRAM pattern. All four test suites plus the 41 existing Behat features run during review, compared against a baseline taken before the first commit
New session fields break a blue/green deployment Low, but high impact Add only, empty defaults, and a round-trip test reading an old record with a new version and the reverse
No OIDC/JWT library supports PHP 8.5 Real Investigate first, before any code is written
The mock provider diverges from DEZI itself Limited DEZI's interface specification is public. The mock provider is built against that specification rather than against assumptions
The link between state and the SAML request fails, losing sessions Medium Reuse the proven lookup by request ID, with the failure path covered in Behat
DEZI attributes bypass the attribute release policy Medium, but only visible in production See §11. Recorded as a known limitation of the proof of concept
Added latency in the login Low on the EngineBlock side The addition is one token call plus a signature check, tens of milliseconds. The JWKS is cached. The real time sits in the redirect to DEZI, outside EngineBlock. Measured with and without the flag
A token or key ends up in a log file Low, but serious Nothing from the OIDC layer is logged beyond start, finish and failure type. Verified with a grep across every logging call in the hook path
The generic layer absorbs a DEZI assumption without anyone noticing Medium; the layers are new The Oidc/ namespace and the four files under library/ are grepped for dezi before the branch is offered for review. The generic unit tests run against a fake merger, so a DEZI dependency breaks them

11. Open points affecting this design

Point What it affects Approach until resolved
Where the profile arrives — through the claims parameter, the token or userinfo, and under which names The DEZI profile reader and the mock provider Take it from the interface specification before the reader is built. The client merges token and userinfo claims either way, so the question stays inside the Dezi/ namespace. Currently the only real gap
Encryption of the identity statement — where it sits, which algorithm, which key The library choice and key management The library has to handle the JWE; the eSeal is the profile reader's work. Both are requirements in the investigation
acr_values for eIDAS high One configuration value Parameter is in place and empty
The subject is pairwise, a different pseudonym per client What goes to the SP as DEZI ID Establish whether the DEZI ID is the subject or a separate claim. This affects the core of what is merged
Which IdP attribute carries the DEZI number The login_hint Configurable, off by default. If the IdPs do not release it, that becomes a rollout question outside this change
Whether DEZI attributes pass the attribute release policy Where in the chain merging happens Currently after the filters, as SRAM does. Moving it earlier moves merging into the filter. This is a choice for the OpenConext maintainers and govroam
Whether the assertion should raise the LoA The output side Currently not. Doing so affects assertion construction and is a heavier change
How the hook triggers in production — per SP, per IdP, through Manage or on LoA The filter The proof of concept uses a flag. The place where the real logic lands is in position
Whether one identity can hold multiple profiles The value object and the mapper DeziProfile is a single set of three. Multiple means a list and a choice of what to release. Confined to the Dezi/ namespace either way
Fail-open or fail-closed when DEZI is down Error handling Currently fail-closed: no profile means no login. The decision rests with govroam
Stepup and the hook together The order in AssertionConsumer Both can be enabled; they run in sequence. Three consecutive user actions in one login is the reason this combination falls outside the proof of concept, and whether it should be supported at all is a question for the OpenConext maintainers and govroam
Whether a second provider is expected Nothing in the code; the split already allows it One instance is configured. A second means a second parameter block and a second merger, with the mechanism unchanged

12. How it is verified

Per component, with unit tests. The mapper is a pure transformation and therefore fully testable.
The OIDC client is tested against a stubbed HTTP layer with fixed keys, so tokens can be signed. One
success case and four failure cases: wrong state, wrong nonce, broken signature and an error
from the provider. The DEZI profile reader is tested separately, against fixed claim sets: one
success case and two failures, a missing statement and an eSeal that does not verify. The filter and
the resume run in tests against fake implementations of the two interfaces, with no network and no
DEZI involved.

The full chain, with Behat. One feature with two scenarios. In the first a user logs in through
the mock IdP, passes the mock DEZI provider and arrives at the mock SP, which shows both attribute
sets. In the second the mock provider returns an error, after which the feedback page appears, the
flow stops, and no DEZI attributes reach the SP.

The mock DEZI provider goes into the functional testing bundle alongside SbsController, with
endpoints for authorize, token, jwks and userinfo. The profile may arrive in the token or via
userinfo (§11); until that is settled the provider serves it in both places, with configuration
determining where the client reads it. Its behavior is driven from a Behat step, exactly as the SBS
server does.

That the layers stay apart. The generic tests use a fake merger, so a DEZI dependency creeping
into Oidc/ or into library/ makes them fail to compile. grep -ri dezi over those paths runs as
part of the same check.

No regression. Before a single line is written, all four test suites and the existing Behat
features run. That is the baseline. At the end they run again and the results are compared.

What the proof of concept does not demonstrate: operation against the DEZI Ontkoppelpunt itself.
DEZI's test environment is live and public; a manual test against it follows once credentials are
available, with the result recorded.


13. Scope of this change, and what follows

The work described here is a proof of concept. It runs in a feature branch on this repository, so it
can be followed while it is under way, and delivers working code on a dedicated development
environment. Nothing is merged during that period.

Bringing the branch to main involves:

  • The real trigger. Not a single flag, but the logic that decides when the hook fires. That likely
    touches Manage and possibly the policy decision point.
  • Stepup interaction. Settle the order.
  • Error handling. A feedback page naming the provider as the cause, and a proper SAML error status
    to the SP instead of an error page.
  • Wider test coverage, including the combinations the proof of concept leaves open.
  • Preparation for rollout.

The starting position for that step: the mechanism is provider-agnostic and carries no DEZI name, the
claim semantics sit behind two interfaces in their own namespace, the changes to existing files are
additive, and the flag is off by default.


14. Feedback requested

Four points, preferably early:

  1. Is the naming split right? The mechanism is named for OIDC and the claim semantics for DEZI,
    which departs from the SRAM and SBS precedent of naming an integration after the system it talks
    to. The reason for the departure is that SRAM's callout is specific to SBS, while this one is a
    standard authorization-code cycle. See §3 and §7.1.
  2. Is the location of the code acceptable? src/OpenConext/EngineBlockBundle/Oidc/ and
    src/OpenConext/EngineBlockBundle/Dezi/ next to Sbs/, with the filter and the service module in
    library/. See §7.1.
  3. Is there a preferred OIDC library, or does a recommendation up front suffice? See §9.
  4. Should DEZI attributes pass the attribute release policy? That determines where in the chain
    merging happens, and moving it after the fact is expensive. See §11.

Comments on any other part of the proposal are welcome in this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions