Skip to content

Commit 6112df4

Browse files
committed
Add client authproc property
1 parent 75578a0 commit 6112df4

15 files changed

Lines changed: 525 additions & 36 deletions

File tree

‎docs/3-oidc-configuration.md‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ $config = [
237237
];
238238
```
239239

240-
## Auth Proc filters (OIDC)
240+
## Authentication Processing filters (OIDC)
241241

242242
Standard SAML Auth Proc Filters do not run during OIDC authN because not
243243
all SAML entities are present (like a Service Provider). Instead, use the
@@ -277,6 +277,58 @@ $config = [
277277
];
278278
```
279279

280+
### Per-client Auth Proc filters
281+
282+
In addition to the global filters above, you can configure Auth Proc filters
283+
for a **specific client (Relying Party)**. This mimics the way SAML allows
284+
filters to be defined in Service Provider metadata.
285+
286+
Client filters are stored together with the client in the database (as part of
287+
the client's extra metadata) and are managed from the client administration UI:
288+
289+
- OIDC > Client Registry > (edit a client) > **Authentication Processing Filters**
290+
291+
The value is entered as a JSON object using the same structure as the global
292+
`authproc.oidc` option (keyed by priority; each entry is a class string or an
293+
object with a `class` property), for example:
294+
295+
```json
296+
{
297+
"60": {
298+
"class": "core:AttributeAdd",
299+
"groups": ["members"]
300+
}
301+
}
302+
```
303+
304+
During authentication for that client, its filters are merged with the global
305+
filters by priority (the global filters run as the "IdP-side" list and the
306+
client filters as the "SP-side" list), exactly as SAML merges IdP and SP
307+
`authproc` filters.
308+
309+
> **Security note:** Auth Proc filters name a PHP class that is instantiated and
310+
> executed on the OP during authentication. For this reason, per-client filters
311+
> can only be set by a trusted administrator through the admin UI / API. They are
312+
> **deliberately never accepted from client-supplied registration metadata**
313+
> (OIDC Dynamic Client Registration or OpenID Federation registration); any such
314+
> value present in registration metadata is ignored. This deny-list of
315+
> administrator-only client properties is defined in
316+
> `\SimpleSAML\Module\oidc\Entities\ClientEntity::ADMIN_ONLY_METADATA_KEYS` and
317+
> enforced in `ClientEntityFactory::fromRegistrationData()`.
318+
319+
Alternatively, if you only need a global filter to run for selected clients, you
320+
can keep using the global `authproc.oidc` option together with a
321+
[preconditional filter](https://simplesamlphp.org/docs/stable/simplesamlphp-authproc.html#preconditional-filters),
322+
inspecting the client ID via `$state['Destination']['entityid']`:
323+
324+
```php
325+
50 => [
326+
'class' => 'core:AttributeAdd',
327+
'groups' => ['members'],
328+
'%precondition' => 'return $state["Destination"]["entityid"] === "https://rp.example.org/";',
329+
],
330+
```
331+
280332
## Client registration permissions
281333

282334
You can allow users to register their own clients. Control this via the

‎docs/6-oidc-upgrade.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ in the OP discovery metadata via the `response_modes_supported` claim.
6161
(`query`, `fragment`, `form_post`) are allowed, so existing clients are
6262
unaffected. It can be narrowed, for example to `form_post` only, to protect
6363
against browser-swapping attacks (if supported by the client).
64+
- Authentication Processing Filters can now be configured per client (Relying
65+
Party), in addition to the global filters defined under `authproc.oidc`. This
66+
mimics defining authproc filters in SAML Service Provider metadata. During
67+
authentication the global (IdP-side) and per-client (SP-side) filters are merged
68+
by priority. The filters are stored together with the client (inside its extra
69+
metadata) and are managed from the client administration UI as a JSON object,
70+
using the same structure as the global filters. For security reasons, per-client
71+
filters can only be set by an administrator (via the admin UI / API) and are
72+
deliberately never accepted from client-supplied dynamic / OpenID Federation
73+
registration metadata (a filter names a PHP class executed on the OP, so
74+
honoring it from registration would be a remote code execution vector).
75+
- Clients can now be configured with a new property related to the above:
76+
- Authentication Processing Filters (`authproc`)
6477
- The encryption key (used to encrypt / decrypt artifacts like authorization
6578
codes and refresh tokens) can now optionally be set to a strong, pre-generated
6679
`\Defuse\Crypto\Key`, instead of always deriving it from the SimpleSAMLphp

‎src/Controllers/Admin/ClientController.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,15 @@ protected function buildClientEntityFromFormData(
370370
$data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : [];
371371
$extraMetadata[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] = $allowedResponseModes;
372372

373+
// Per-client authproc filters. These are administrator-only (settable
374+
// here, via the admin UI), and are deliberately never accepted from
375+
// client-supplied registration metadata. See
376+
// ClientEntityFactory::fromRegistrationData() and
377+
// ClientEntity::ADMIN_ONLY_METADATA_KEYS.
378+
$authProcFilters = is_array($data[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null) ?
379+
$data[ClientEntity::KEY_AUTH_PROC_FILTERS] : [];
380+
$extraMetadata[ClientEntity::KEY_AUTH_PROC_FILTERS] = $authProcFilters;
381+
373382
return $this->clientEntityFactory->fromData(
374383
$identifier,
375384
$secret,

‎src/Entities/ClientEntity.php‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ class ClientEntity implements ClientEntityInterface
5656
public const string KEY_IS_GENERIC = 'is_generic';
5757
public const string KEY_EXTRA_METADATA = 'extra_metadata';
5858
public const string KEY_ALLOWED_RESPONSE_MODES = 'allowed_response_modes';
59+
/**
60+
* Per-client Authentication Processing Filters. Stored as an entry inside
61+
* the extra metadata JSON blob.
62+
*/
63+
public const string KEY_AUTH_PROC_FILTERS = 'authproc';
64+
65+
/**
66+
* Client properties (metadata keys) which are "administrator-only":
67+
* they may only be set by a trusted administrator (via the admin UI / API,
68+
* i.e. ClientEntityFactory::fromData()), and MUST NOT be honored when they
69+
* arrive in client-supplied registration metadata (OIDC Dynamic Client
70+
* Registration or OpenID Federation). See the deny-list handling and the
71+
* accompanying explanation in
72+
* \SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory::fromRegistrationData().
73+
*
74+
* @var string[]
75+
*/
76+
public const array ADMIN_ONLY_METADATA_KEYS = [
77+
self::KEY_AUTH_PROC_FILTERS,
78+
];
5979

6080

6181
private string $secret;
@@ -236,6 +256,7 @@ public function toArray(): array
236256
ClaimsEnum::RequirePushedAuthorizationRequests->value => $this->getRequirePushedAuthorizationRequests(),
237257
ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(),
238258
ClaimsEnum::RequestUris->value => $this->getRequestUris(),
259+
self::KEY_AUTH_PROC_FILTERS => $this->getAuthProcFilters(),
239260
];
240261
}
241262

@@ -429,6 +450,25 @@ public function getRequireSignedRequestObject(): bool
429450
return (bool)($this->extraMetadata[ClaimsEnum::RequireSignedRequestObject->value] ?? false);
430451
}
431452

453+
/**
454+
* Per-client Authentication Processing Filters, in the same format as the
455+
* global ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS option. These run, in
456+
* addition to the global filters, during authentication for this client
457+
* (the SimpleSAMLphp ProcessingChain merges them as the "SP" side filters).
458+
*
459+
* @return array
460+
*/
461+
public function getAuthProcFilters(): array
462+
{
463+
if (!is_array($this->extraMetadata)) {
464+
return [];
465+
}
466+
467+
$authProcFilters = $this->extraMetadata[self::KEY_AUTH_PROC_FILTERS] ?? null;
468+
469+
return is_array($authProcFilters) ? $authProcFilters : [];
470+
}
471+
432472
/**
433473
* @return string[]
434474
*/

‎src/Entities/Interfaces/ClientEntityInterface.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,9 @@ public function getRequireSignedRequestObject(): bool;
8989
* @return string[]
9090
*/
9191
public function getRequestUris(): array;
92+
93+
/**
94+
* @return array
95+
*/
96+
public function getAuthProcFilters(): array;
9297
}

‎src/Factories/Entities/ClientEntityFactory.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,32 @@ public function fromRegistrationData(
106106
?string $clientIdentifier = null,
107107
?array $federationJwks = null,
108108
): ClientEntityInterface {
109+
// Security: scrub administrator-only properties from client-supplied
110+
// registration metadata.
111+
//
112+
// This method builds clients from metadata provided by a remote party,
113+
// i.e. through OIDC Dynamic Client Registration (RFC 7591) or OpenID
114+
// Federation (explicit / automatic) registration. Some client properties
115+
// must never be controllable by the registering party, because honoring
116+
// them would let an untrusted client influence server-side behavior. The
117+
// prime example is `authproc` (per-client Authentication Processing
118+
// Filters): a filter entry names a PHP class that is instantiated and
119+
// executed on the OP during authentication, so accepting it from
120+
// registration metadata would be a remote code execution vector.
121+
//
122+
// Such properties are settable ONLY by a trusted administrator, via the
123+
// admin UI / API (ClientEntityFactory::fromData()). We strip every
124+
// deny-listed key from the incoming metadata here, so it can neither be
125+
// read below nor leak into a future code path. Any value an administrator
126+
// has already set on an existing client is preserved because it is
127+
// carried over from $existingClient->getExtraMetadata() further down (it
128+
// does not come from $metadata).
129+
//
130+
// The deny-list itself lives next to the property definitions, in
131+
// ClientEntity::ADMIN_ONLY_METADATA_KEYS.
132+
foreach (ClientEntity::ADMIN_ONLY_METADATA_KEYS as $adminOnlyMetadataKey) {
133+
unset($metadata[$adminOnlyMetadataKey]);
134+
}
109135

110136
$id = $clientIdentifier ?? $existingClient?->getIdentifier() ??
111137
$this->sspBridge->utils()->random()->generateID();

‎src/Factories/ProcessingChainFactory.php‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,29 @@
1212
namespace SimpleSAML\Module\oidc\Factories;
1313

1414
use SimpleSAML\Auth\ProcessingChain;
15-
use SimpleSAML\Module\oidc\ModuleConfig;
1615

1716
class ProcessingChainFactory
1817
{
19-
public function __construct(
20-
private readonly ModuleConfig $moduleConfig,
21-
) {
22-
}
23-
2418
/**
2519
* @codeCoverageIgnore
2620
* @throws \Exception
2721
*/
2822
public function build(array $state): ProcessingChain
2923
{
24+
// The IdP- and SP-side metadata (entityid + authproc filter lists) is
25+
// the single source of truth prepared in
26+
// AuthenticationService::runAuthProcs() and stored in the state.
27+
// Here we only consume it;
28+
// The IdP side carries the global authproc filters, the SP side the
29+
// per-client ones, and the SimpleSAMLphp ProcessingChain merges them
30+
// by priority.
3031
$idpMetadata = [
3132
'entityid' => $state['Source']['entityid'] ?? '',
32-
// ProcessChain needs to know the list of authproc filters we defined in module_oidc configuration
33-
'authproc' => $this->moduleConfig->getAuthProcFilters(),
33+
'authproc' => $state['Source']['authproc'] ?? [],
3434
];
3535
$spMetadata = [
3636
'entityid' => $state['Destination']['entityid'] ?? '',
37+
'authproc' => $state['Destination']['authproc'] ?? [],
3738
];
3839

3940
return new ProcessingChain(

‎src/Forms/ClientForm.php‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,71 @@ public function validateRequestUris(Form $form): void
202202
}
203203
}
204204

205+
/**
206+
* Validate the per-client Authentication Processing Filters. The value is
207+
* expected to be a JSON object/array in the same shape as the global
208+
* ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS option, i.e. a list keyed by
209+
* priority where each filter is either a class string or an array with a
210+
* 'class' key. JSON syntax itself is validated in getValues().
211+
*
212+
* @throws \Exception
213+
*/
214+
public function validateAuthProcFilters(Form $form): void
215+
{
216+
$values = $form->getValues(self::TYPE_ARRAY);
217+
218+
$authProcFilters = $values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? [];
219+
220+
if (!is_array($authProcFilters)) {
221+
$this->addError('Authentication Processing Filters must be a JSON object.');
222+
return;
223+
}
224+
225+
/**
226+
* @var mixed $filter
227+
*/
228+
foreach ($authProcFilters as $filter) {
229+
if (is_string($filter)) {
230+
continue;
231+
}
232+
233+
if (!is_array($filter)) {
234+
$this->addError(
235+
'Each Authentication Processing Filter must be a class string or an object: ' .
236+
var_export($filter, true),
237+
);
238+
continue;
239+
}
240+
241+
if (!isset($filter['class']) || !is_string($filter['class'])) {
242+
$this->addError(
243+
"Each Authentication Processing Filter object must have a string 'class' property: " .
244+
var_export($filter, true),
245+
);
246+
}
247+
}
248+
}
249+
250+
/**
251+
* Cast integer-like string array keys to int, leaving all other keys (and
252+
* the values) untouched. Only the top level is processed, which is where
253+
* authproc filter priority keys live. Used to normalize priorities coming
254+
* from the JSON-encoded authproc filters field.
255+
*/
256+
protected function castNumericKeysToInt(array $array): array
257+
{
258+
$result = [];
259+
/** @var mixed $value */
260+
foreach ($array as $key => $value) {
261+
if (is_string($key) && preg_match('/^-?\d+$/', $key) === 1) {
262+
$key = (int) $key;
263+
}
264+
$result[$key] = $value;
265+
}
266+
267+
return $result;
268+
}
269+
205270
public function validateJwks(mixed $jwks): void
206271
{
207272
if (is_null($jwks)) {
@@ -319,6 +384,24 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co
319384
array_keys($this->getAllowedResponseModesValues()),
320385
);
321386

387+
$authProcFilters = trim((string)($values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? ''));
388+
try {
389+
/** @psalm-suppress MixedAssignment */
390+
$decodedAuthProcFilters = $authProcFilters === '' ?
391+
[] :
392+
json_decode($authProcFilters, true, 512, JSON_THROW_ON_ERROR);
393+
// Normalize numeric priority keys to integers. PHP already casts
394+
// canonical integer string keys (e.g. "60") to int, but not forms
395+
// like "08", so we make the priority type predictable for the
396+
// SimpleSAMLphp ProcessingChain.
397+
$values[ClientEntity::KEY_AUTH_PROC_FILTERS] = is_array($decodedAuthProcFilters) ?
398+
$this->castNumericKeysToInt($decodedAuthProcFilters) :
399+
$decodedAuthProcFilters;
400+
} catch (\JsonException $e) {
401+
$this->addError('Authentication Processing Filters JSON error: ' . $e->getMessage());
402+
$values[ClientEntity::KEY_AUTH_PROC_FILTERS] = [];
403+
}
404+
322405
return $values;
323406
}
324407

@@ -390,6 +473,11 @@ public function setDefaults(object|array $values, bool $erase = false): static
390473
$values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES],
391474
) ? $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : [];
392475

476+
$authProcFilters = $values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null;
477+
$values[ClientEntity::KEY_AUTH_PROC_FILTERS] = (is_array($authProcFilters) && $authProcFilters !== []) ?
478+
(string)json_encode($authProcFilters, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) :
479+
'';
480+
393481
parent::setDefaults($values, $erase);
394482

395483
return $this;
@@ -413,6 +501,7 @@ protected function buildForm(): void
413501
$this->onValidate[] = $this->validateProtocolJwks(...);
414502
$this->onValidate[] = $this->validateJwksUri(...);
415503
$this->onValidate[] = $this->validateRequestUris(...);
504+
$this->onValidate[] = $this->validateAuthProcFilters(...);
416505

417506
$this->setMethod('POST');
418507
$this->addComponent($this->csrfProtection, Form::ProtectorId);
@@ -494,6 +583,13 @@ protected function buildForm(): void
494583
$this->addCheckbox(ClaimsEnum::RequireSignedRequestObject->value, 'Require Signed Request Object');
495584
$this->addTextArea(ClaimsEnum::RequestUris->value, 'Request URIs (OIDC Core / JAR, one per line)', null, 5)
496585
->setHtmlAttribute('class', 'full-width');
586+
587+
$this->addTextArea(
588+
ClientEntity::KEY_AUTH_PROC_FILTERS,
589+
Translate::noop('Authentication Processing Filters'),
590+
null,
591+
5,
592+
)->setHtmlAttribute('class', 'full-width');
497593
}
498594

499595
/**

0 commit comments

Comments
 (0)