diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md
index 6142ea12..9f450c1b 100644
--- a/docs/3-oidc-configuration.md
+++ b/docs/3-oidc-configuration.md
@@ -237,7 +237,7 @@ $config = [
];
```
-## Auth Proc filters (OIDC)
+## Authentication Processing filters (OIDC)
Standard SAML Auth Proc Filters do not run during OIDC authN because not
all SAML entities are present (like a Service Provider). Instead, use the
@@ -277,6 +277,58 @@ $config = [
];
```
+### Per-client Auth Proc filters
+
+In addition to the global filters above, you can configure Auth Proc filters
+for a **specific client (Relying Party)**. This mimics the way SAML allows
+filters to be defined in Service Provider metadata.
+
+Client filters are stored together with the client in the database (as part of
+the client's extra metadata) and are managed from the client administration UI:
+
+- OIDC > Client Registry > (edit a client) > **Authentication Processing Filters**
+
+The value is entered as a JSON object using the same structure as the global
+`authproc.oidc` option (keyed by priority; each entry is a class string or an
+object with a `class` property), for example:
+
+```json
+{
+ "60": {
+ "class": "core:AttributeAdd",
+ "groups": ["members"]
+ }
+}
+```
+
+During authentication for that client, its filters are merged with the global
+filters by priority (the global filters run as the "IdP-side" list and the
+client filters as the "SP-side" list), exactly as SAML merges IdP and SP
+`authproc` filters.
+
+> **Security note:** Auth Proc filters name a PHP class that is instantiated and
+> executed on the OP during authentication. For this reason, per-client filters
+> can only be set by a trusted administrator through the admin UI / API. They are
+> **deliberately never accepted from client-supplied registration metadata**
+> (OIDC Dynamic Client Registration or OpenID Federation registration); any such
+> value present in registration metadata is ignored. This deny-list of
+> administrator-only client properties is defined in
+> `\SimpleSAML\Module\oidc\Entities\ClientEntity::ADMIN_ONLY_METADATA_KEYS` and
+> enforced in `ClientEntityFactory::fromRegistrationData()`.
+
+Alternatively, if you only need a global filter to run for selected clients, you
+can keep using the global `authproc.oidc` option together with a
+[preconditional filter](https://simplesamlphp.org/docs/stable/simplesamlphp-authproc.html#preconditional-filters),
+inspecting the client ID via `$state['Destination']['entityid']`:
+
+```php
+50 => [
+ 'class' => 'core:AttributeAdd',
+ 'groups' => ['members'],
+ '%precondition' => 'return $state["Destination"]["entityid"] === "https://rp.example.org/";',
+],
+```
+
## Client registration permissions
You can allow users to register their own clients. Control this via the
diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md
index 44a084dd..c04106a5 100644
--- a/docs/6-oidc-upgrade.md
+++ b/docs/6-oidc-upgrade.md
@@ -61,6 +61,19 @@ in the OP discovery metadata via the `response_modes_supported` claim.
(`query`, `fragment`, `form_post`) are allowed, so existing clients are
unaffected. It can be narrowed, for example to `form_post` only, to protect
against browser-swapping attacks (if supported by the client).
+- Authentication Processing Filters can now be configured per client (Relying
+Party), in addition to the global filters defined under `authproc.oidc`. This
+mimics defining authproc filters in SAML Service Provider metadata. During
+authentication the global (IdP-side) and per-client (SP-side) filters are merged
+by priority. The filters are stored together with the client (inside its extra
+metadata) and are managed from the client administration UI as a JSON object,
+using the same structure as the global filters. For security reasons, per-client
+filters can only be set by an administrator (via the admin UI / API) and are
+deliberately never accepted from client-supplied dynamic / OpenID Federation
+registration metadata (a filter names a PHP class executed on the OP, so
+honoring it from registration would be a remote code execution vector).
+ - Clients can now be configured with a new property related to the above:
+ - Authentication Processing Filters (`authproc`)
- The encryption key (used to encrypt / decrypt artifacts like authorization
codes and refresh tokens) can now optionally be set to a strong, pre-generated
`\Defuse\Crypto\Key`, instead of always deriving it from the SimpleSAMLphp
diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php
index 1ca4490b..0a2a4bde 100644
--- a/src/Controllers/Admin/ClientController.php
+++ b/src/Controllers/Admin/ClientController.php
@@ -370,6 +370,16 @@ protected function buildClientEntityFromFormData(
$data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : [];
$extraMetadata[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] = $allowedResponseModes;
+ // Per-client authproc filters. These are administrator-only (settable
+ // here, via the admin UI), and are deliberately never accepted from
+ // client-supplied registration metadata. See
+ // ClientEntityFactory::fromRegistrationData() and
+ // ClientEntity::ADMIN_ONLY_METADATA_KEYS.
+ /** @var mixed $rawAuthProcFilters */
+ $rawAuthProcFilters = $data[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null;
+ $extraMetadata[ClientEntity::KEY_AUTH_PROC_FILTERS] = is_array($rawAuthProcFilters) ?
+ $rawAuthProcFilters : [];
+
return $this->clientEntityFactory->fromData(
$identifier,
$secret,
diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php
index a3ad4749..72ed1354 100644
--- a/src/Entities/ClientEntity.php
+++ b/src/Entities/ClientEntity.php
@@ -56,6 +56,26 @@ class ClientEntity implements ClientEntityInterface
public const string KEY_IS_GENERIC = 'is_generic';
public const string KEY_EXTRA_METADATA = 'extra_metadata';
public const string KEY_ALLOWED_RESPONSE_MODES = 'allowed_response_modes';
+ /**
+ * Per-client Authentication Processing Filters. Stored as an entry inside
+ * the extra metadata JSON blob.
+ */
+ public const string KEY_AUTH_PROC_FILTERS = 'authproc';
+
+ /**
+ * Client properties (metadata keys) which are "administrator-only":
+ * they may only be set by a trusted administrator (via the admin UI / API,
+ * i.e. ClientEntityFactory::fromData()), and MUST NOT be honored when they
+ * arrive in client-supplied registration metadata (OIDC Dynamic Client
+ * Registration or OpenID Federation). See the deny-list handling and the
+ * accompanying explanation in
+ * \SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory::fromRegistrationData().
+ *
+ * @var string[]
+ */
+ public const array ADMIN_ONLY_METADATA_KEYS = [
+ self::KEY_AUTH_PROC_FILTERS,
+ ];
private string $secret;
@@ -236,6 +256,7 @@ public function toArray(): array
ClaimsEnum::RequirePushedAuthorizationRequests->value => $this->getRequirePushedAuthorizationRequests(),
ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(),
ClaimsEnum::RequestUris->value => $this->getRequestUris(),
+ self::KEY_AUTH_PROC_FILTERS => $this->getAuthProcFilters(),
];
}
@@ -429,6 +450,26 @@ public function getRequireSignedRequestObject(): bool
return (bool)($this->extraMetadata[ClaimsEnum::RequireSignedRequestObject->value] ?? false);
}
+ /**
+ * Per-client Authentication Processing Filters, in the same format as the
+ * global ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS option. These run, in
+ * addition to the global filters, during authentication for this client
+ * (the SimpleSAMLphp ProcessingChain merges them as the "SP" side filters).
+ *
+ * @return array
+ */
+ public function getAuthProcFilters(): array
+ {
+ if (!is_array($this->extraMetadata)) {
+ return [];
+ }
+
+ /** @var mixed $authProcFilters */
+ $authProcFilters = $this->extraMetadata[self::KEY_AUTH_PROC_FILTERS] ?? null;
+
+ return is_array($authProcFilters) ? $authProcFilters : [];
+ }
+
/**
* @return string[]
*/
diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php
index 9ca01687..6d66c544 100644
--- a/src/Entities/Interfaces/ClientEntityInterface.php
+++ b/src/Entities/Interfaces/ClientEntityInterface.php
@@ -89,4 +89,9 @@ public function getRequireSignedRequestObject(): bool;
* @return string[]
*/
public function getRequestUris(): array;
+
+ /**
+ * @return array
+ */
+ public function getAuthProcFilters(): array;
}
diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php
index 4f03ca6a..79224cd4 100644
--- a/src/Factories/Entities/ClientEntityFactory.php
+++ b/src/Factories/Entities/ClientEntityFactory.php
@@ -106,6 +106,32 @@ public function fromRegistrationData(
?string $clientIdentifier = null,
?array $federationJwks = null,
): ClientEntityInterface {
+ // Security: scrub administrator-only properties from client-supplied
+ // registration metadata.
+ //
+ // This method builds clients from metadata provided by a remote party,
+ // i.e. through OIDC Dynamic Client Registration (RFC 7591) or OpenID
+ // Federation (explicit / automatic) registration. Some client properties
+ // must never be controllable by the registering party, because honoring
+ // them would let an untrusted client influence server-side behavior. The
+ // prime example is `authproc` (per-client Authentication Processing
+ // Filters): a filter entry names a PHP class that is instantiated and
+ // executed on the OP during authentication, so accepting it from
+ // registration metadata would be a remote code execution vector.
+ //
+ // Such properties are settable ONLY by a trusted administrator, via the
+ // admin UI / API (ClientEntityFactory::fromData()). We strip every
+ // deny-listed key from the incoming metadata here, so it can neither be
+ // read below nor leak into a future code path. Any value an administrator
+ // has already set on an existing client is preserved because it is
+ // carried over from $existingClient->getExtraMetadata() further down (it
+ // does not come from $metadata).
+ //
+ // The deny-list itself lives next to the property definitions, in
+ // ClientEntity::ADMIN_ONLY_METADATA_KEYS.
+ foreach (ClientEntity::ADMIN_ONLY_METADATA_KEYS as $adminOnlyMetadataKey) {
+ unset($metadata[$adminOnlyMetadataKey]);
+ }
$id = $clientIdentifier ?? $existingClient?->getIdentifier() ??
$this->sspBridge->utils()->random()->generateID();
diff --git a/src/Factories/ProcessingChainFactory.php b/src/Factories/ProcessingChainFactory.php
index 6a6afd21..3ca0e4f1 100644
--- a/src/Factories/ProcessingChainFactory.php
+++ b/src/Factories/ProcessingChainFactory.php
@@ -12,28 +12,29 @@
namespace SimpleSAML\Module\oidc\Factories;
use SimpleSAML\Auth\ProcessingChain;
-use SimpleSAML\Module\oidc\ModuleConfig;
class ProcessingChainFactory
{
- public function __construct(
- private readonly ModuleConfig $moduleConfig,
- ) {
- }
-
/**
* @codeCoverageIgnore
* @throws \Exception
*/
public function build(array $state): ProcessingChain
{
+ // The IdP- and SP-side metadata (entityid + authproc filter lists) is
+ // the single source of truth prepared in
+ // AuthenticationService::runAuthProcs() and stored in the state.
+ // Here we only consume it;
+ // The IdP side carries the global authproc filters, the SP side the
+ // per-client ones, and the SimpleSAMLphp ProcessingChain merges them
+ // by priority.
$idpMetadata = [
'entityid' => $state['Source']['entityid'] ?? '',
- // ProcessChain needs to know the list of authproc filters we defined in module_oidc configuration
- 'authproc' => $this->moduleConfig->getAuthProcFilters(),
+ 'authproc' => $state['Source']['authproc'] ?? [],
];
$spMetadata = [
'entityid' => $state['Destination']['entityid'] ?? '',
+ 'authproc' => $state['Destination']['authproc'] ?? [],
];
return new ProcessingChain(
diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php
index 0f444fc1..e85685ce 100644
--- a/src/Forms/ClientForm.php
+++ b/src/Forms/ClientForm.php
@@ -202,6 +202,72 @@ public function validateRequestUris(Form $form): void
}
}
+ /**
+ * Validate the per-client Authentication Processing Filters. The value is
+ * expected to be a JSON object/array in the same shape as the global
+ * ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS option, i.e. a list keyed by
+ * priority where each filter is either a class string or an array with a
+ * 'class' key. JSON syntax itself is validated in getValues().
+ *
+ * @throws \Exception
+ */
+ public function validateAuthProcFilters(Form $form): void
+ {
+ $values = $form->getValues(self::TYPE_ARRAY);
+
+ $authProcFilters = $values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? [];
+
+ if (!is_array($authProcFilters)) {
+ $this->addError('Authentication Processing Filters must be a JSON object.');
+ return;
+ }
+
+ /**
+ * @var mixed $filter
+ */
+ foreach ($authProcFilters as $filter) {
+ if (is_string($filter)) {
+ continue;
+ }
+
+ if (!is_array($filter)) {
+ $this->addError(
+ 'Each Authentication Processing Filter must be a class string or an object: ' .
+ var_export($filter, true),
+ );
+ continue;
+ }
+
+ if (!isset($filter['class']) || !is_string($filter['class'])) {
+ $this->addError(
+ "Each Authentication Processing Filter object must have a string 'class' property: " .
+ var_export($filter, true),
+ );
+ }
+ }
+ }
+
+ /**
+ * Cast integer-like string array keys to int, leaving all other keys (and
+ * the values) untouched. Only the top level is processed, which is where
+ * authproc filter priority keys live. Used to normalize priorities coming
+ * from the JSON-encoded authproc filters field.
+ */
+ protected function castNumericKeysToInt(array $array): array
+ {
+ $result = [];
+ /** @var mixed $value */
+ foreach ($array as $key => $value) {
+ if (is_string($key) && preg_match('/^-?\d+$/', $key) === 1) {
+ $key = (int) $key;
+ }
+ /** @psalm-suppress MixedAssignment */
+ $result[$key] = $value;
+ }
+
+ return $result;
+ }
+
public function validateJwks(mixed $jwks): void
{
if (is_null($jwks)) {
@@ -319,6 +385,25 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co
array_keys($this->getAllowedResponseModesValues()),
);
+ $authProcFilters = trim((string)($values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? ''));
+ try {
+ /** @psalm-suppress MixedAssignment */
+ $decodedAuthProcFilters = $authProcFilters === '' ?
+ [] :
+ json_decode($authProcFilters, true, 512, JSON_THROW_ON_ERROR);
+ // Normalize numeric priority keys to integers. PHP already casts
+ // canonical integer string keys (e.g. "60") to int, but not forms
+ // like "08", so we make the priority type predictable for the
+ // SimpleSAMLphp ProcessingChain.
+ /** @psalm-suppress MixedAssignment */
+ $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = is_array($decodedAuthProcFilters) ?
+ $this->castNumericKeysToInt($decodedAuthProcFilters) :
+ $decodedAuthProcFilters;
+ } catch (\JsonException $e) {
+ $this->addError('Authentication Processing Filters JSON error: ' . $e->getMessage());
+ $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = [];
+ }
+
return $values;
}
@@ -361,9 +446,10 @@ public function setDefaults(object|array $values, bool $erase = false): static
[ClientRegistrationTypesEnum::Automatic->value];
$values['federation_jwks'] = is_array($values['federation_jwks']) ?
- json_encode($values['federation_jwks']) : null;
+ json_encode($values['federation_jwks'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : null;
- $values['jwks'] = is_array($values['jwks']) ? json_encode($values['jwks']) : null;
+ $values['jwks'] = is_array($values['jwks']) ?
+ json_encode($values['jwks'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : null;
if (
$values['auth_source'] !== null &&
@@ -390,6 +476,12 @@ public function setDefaults(object|array $values, bool $erase = false): static
$values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES],
) ? $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : [];
+ /** @var mixed $authProcFilters */
+ $authProcFilters = $values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null;
+ $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = (is_array($authProcFilters) && $authProcFilters !== []) ?
+ (string)json_encode($authProcFilters, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) :
+ '';
+
parent::setDefaults($values, $erase);
return $this;
@@ -413,6 +505,7 @@ protected function buildForm(): void
$this->onValidate[] = $this->validateProtocolJwks(...);
$this->onValidate[] = $this->validateJwksUri(...);
$this->onValidate[] = $this->validateRequestUris(...);
+ $this->onValidate[] = $this->validateAuthProcFilters(...);
$this->setMethod('POST');
$this->addComponent($this->csrfProtection, Form::ProtectorId);
@@ -494,6 +587,13 @@ protected function buildForm(): void
$this->addCheckbox(ClaimsEnum::RequireSignedRequestObject->value, 'Require Signed Request Object');
$this->addTextArea(ClaimsEnum::RequestUris->value, 'Request URIs (OIDC Core / JAR, one per line)', null, 5)
->setHtmlAttribute('class', 'full-width');
+
+ $this->addTextArea(
+ ClientEntity::KEY_AUTH_PROC_FILTERS,
+ Translate::noop('Authentication Processing Filters'),
+ null,
+ 5,
+ )->setHtmlAttribute('class', 'full-width');
}
/**
diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php
index 407b30af..73322b7b 100644
--- a/src/Services/AuthenticationService.php
+++ b/src/Services/AuthenticationService.php
@@ -27,6 +27,7 @@
use SimpleSAML\Error\NoState;
use SimpleSAML\Module\oidc\Codebooks\RoutesEnum;
use SimpleSAML\Module\oidc\Controllers\EndSessionController;
+use SimpleSAML\Module\oidc\Entities\ClientEntity;
use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
use SimpleSAML\Module\oidc\Entities\UserEntity;
use SimpleSAML\Module\oidc\Exceptions\OidcException;
@@ -346,10 +347,17 @@ public function manageState(array $queryParameters): ?array
}
/**
- * Run authproc filters with the processing chain
- * Creating the ProcessingChain require metadata.
- * - For the idp metadata use the OIDC issuer as the entityId (and the authprocs from the main config file)
- * - For the sp metadata use the client id as the entityId (and don’t set authprocs).
+ * Run authproc filters with the processing chain.
+ *
+ * This is the single source of truth for the metadata that drives the
+ * ProcessingChain. We build it once here and store it back into the state;
+ * the ProcessingChainFactory then only consumes the prepared state:
+ * - IdP metadata uses the OIDC issuer as the entityId and carries the global
+ * authproc filters (from the module configuration).
+ * - SP metadata uses the client ID as the entityId and carries the
+ * per-client authproc filters (from the client's metadata).
+ * The SimpleSAMLphp ProcessingChain then merges both filter lists by
+ * priority, mimicking SAML IdP + SP authproc filters.
*
* @param array $state
*
@@ -360,19 +368,37 @@ public function manageState(array $queryParameters): ?array
*/
protected function runAuthProcs(array &$state): void
{
- $idpMetadata = [
+ $state['ReturnURL'] = $this->routes->getModuleUrl(RoutesEnum::Authorization->value);
+
+ // Note: we only augment the existing Source / Destination entries (which
+ // already carry the 'entityid' set in prepareStateArray()) with their
+ // respective authproc filter lists. No state keys are removed.
+ $state['Source'] = [
'entityid' => $state['Source']['entityid'] ?? '',
- // ProcessChain needs to know the list of authproc filters we defined in module_oidc configuration
'authproc' => $this->moduleConfig->getAuthProcFilters(),
];
- $spMetadata = [
+ $state['Destination'] = [
'entityid' => $state['Destination']['entityid'] ?? '',
+ 'authproc' => $this->resolveClientAuthProcFilters($state),
];
- $state['ReturnURL'] = $this->routes->getModuleUrl(RoutesEnum::Authorization->value);
- $state['Destination'] = $spMetadata;
- $state['Source'] = $idpMetadata;
-
$this->processingChainFactory->build($state)->processState($state);
}
+
+ /**
+ * Resolve per-client authproc filters from the OIDC relying party metadata
+ * present in the authentication state (exposed there by prepareStateArray()).
+ */
+ protected function resolveClientAuthProcFilters(array $state): array
+ {
+ $relyingPartyMetadata = $state['Oidc']['RelyingPartyMetadata'] ?? null;
+ if (!is_array($relyingPartyMetadata)) {
+ return [];
+ }
+
+ /** @var mixed $authProcFilters */
+ $authProcFilters = $relyingPartyMetadata[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null;
+
+ return is_array($authProcFilters) ? $authProcFilters : [];
+ }
}
diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig
index ac648687..99a50724 100644
--- a/templates/clients/includes/form.twig
+++ b/templates/clients/includes/form.twig
@@ -206,6 +206,15 @@
{% trans %}Require this client to always sign a request object (even in case of OpenID Connect flavor which does not require signing). Make sure the client has a JWKS key configured if this option is enabled.{% endtrans %}
+
+ {{ form.authproc.control | raw }}
+
+ {% if form.authproc.hasErrors %}
+
+ {% endif %}
+
+ {{- client.authProcFilters|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}}
+
+ {% else %}
+ {{ 'N/A'|trans }}
+ {% endif %}
+