Skip to content
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
54 changes: 53 additions & 1 deletion docs/3-oidc-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/6-oidc-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/Controllers/Admin/ClientController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions src/Entities/ClientEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
];
}

Expand Down Expand Up @@ -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[]
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Entities/Interfaces/ClientEntityInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,9 @@ public function getRequireSignedRequestObject(): bool;
* @return string[]
*/
public function getRequestUris(): array;

/**
* @return array
*/
public function getAuthProcFilters(): array;
}
26 changes: 26 additions & 0 deletions src/Factories/Entities/ClientEntityFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 9 additions & 8 deletions src/Factories/ProcessingChainFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
104 changes: 102 additions & 2 deletions src/Forms/ClientForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 &&
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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');
}

/**
Expand Down
Loading
Loading