Skip to content

Commit ecd7002

Browse files
committed
WIP
1 parent d50f8dd commit ecd7002

8 files changed

Lines changed: 160 additions & 52 deletions

File tree

docs/6-oidc-upgrade.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,20 @@ per-field metadata policy (honored / validated / rejected) is documented in
149149
`response_types`, and `token_endpoint_auth_method` is presence-based, so existing
150150
clients (which do not have these properties set) are **not** restricted and
151151
require no action — their behavior is unchanged. The OIDC DCR defaults are
152-
applied only to newly, dynamically registered clients, not retroactively. If you
153-
want to start constraining an existing client to specific grant types / response
154-
types / authentication method, set those properties on the client in the admin
155-
UI; leaving them empty preserves the previous unconstrained behavior. DCR is also
156-
opt-in (disabled by default), so unless you enable it, nothing changes for your
157-
deployment.
152+
applied only to newly, dynamically registered clients, not retroactively. In the
153+
admin UI, a pre-upgrade client shows these new fields as **unset** (no grant
154+
types / response types selected, no authentication method chosen) — that is the
155+
honest representation of "not registered", and saving such a client does **not**
156+
silently impose the spec defaults or otherwise constrain it. To start constraining
157+
an existing client to specific grant types / response types / authentication
158+
method, select them explicitly and save; leaving them empty preserves the
159+
unconstrained behavior. (Implementation note: in v7 the client getters for these
160+
fields return the raw registered value — empty / null when unset — rather than the
161+
OIDC DCR spec default, so the stored value is the single source of truth; the
162+
defaults are applied where it matters, i.e. when a client registers dynamically. A
163+
future major version may switch the getters to fall back to the spec defaults.)
164+
DCR is also opt-in (disabled by default), so unless you enable it, nothing changes
165+
for your deployment.
158166

159167
New configuration options:
160168

docs/9-oidc-dcr-client-metadata.md

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ contract for what was honored.
3232
| `redirect_uris` | Honored | Honored | Required; scheme required, fragment rejected. |
3333
| `client_name` | Honored | Honored | Defaults to client_id. |
3434
| `scope` | Honored | Honored | DCR default = `OPTION_DCR_DEFAULT_SCOPES`. |
35-
| `grant_types` | **Honored** (persist + echo + enforce) | Honored | Default `["authorization_code"]`. Enforced for the code grant; refresh grant exempt (see note). |
36-
| `response_types` | **Honored** (persist + echo + enforce) | Honored | Default `["code"]`. Enforced at the authorization endpoint. |
37-
| `token_endpoint_auth_method` | **Honored** (persist + echo + enforce) | Honored | Default `client_secret_basic` (or `none` for public). Enforced at the token endpoint. |
35+
| `grant_types` | **Honored** (persist + echo + enforce) | Honored | DCR default `["authorization_code"]` stored at registration. Enforced for the code grant (presence + non-empty); refresh grant exempt (see note). |
36+
| `response_types` | **Honored** (persist + echo + enforce) | Honored | DCR default `["code"]` stored at registration. Enforced at the authorization endpoint (presence + non-empty). |
37+
| `token_endpoint_auth_method` | **Honored** (persist + echo + enforce) | Honored | DCR default `client_secret_basic` (or `none` for public) stored at registration. Enforced at the token endpoint (presence). |
3838
| `jwks` | Honored | Honored | Stored (column). |
3939
| `jwks_uri` | Honored | Honored | Stored (column); fetched for client auth / request objects. |
4040
| `signed_jwks_uri` | Honored | Honored | Stored (column). |
@@ -70,12 +70,34 @@ contract for what was honored.
7070

7171
Per-client enforcement of `grant_types` / `response_types` /
7272
`token_endpoint_auth_method` is **presence-based**: a field is enforced for a
73-
client only when that client has it explicitly registered. Dynamically registered
74-
clients always do (the OIDC DCR defaults are applied at registration); clients
75-
that do not have it configured are not constrained. This avoids regressing
76-
manually-managed clients while still honoring the registered metadata. All client
77-
metadata is stored in the existing `extra_metadata` JSON column (no DB migration),
78-
and is exposed as editable fields in the admin UI.
73+
client only when that client has it explicitly registered. For the array-valued
74+
fields (`grant_types`, `response_types`) a present-but-**empty** list also counts as
75+
"not configured" and is not enforced (it never means "allow nothing"; this matches
76+
the admin form's "if none are selected, the client is not restricted"). Dynamically
77+
registered clients always have these stored (the OIDC DCR defaults are applied at
78+
registration); clients that do not have them configured are not constrained. This
79+
avoids regressing manually-managed and pre-DCR clients while still honoring the
80+
registered metadata. All client metadata is stored in the existing `extra_metadata`
81+
JSON column (no DB migration), and is exposed as editable fields in the admin UI.
82+
83+
### Single source of truth (v7 transition)
84+
85+
The entity getters for these fields (`getGrantTypes()`, `getResponseTypes()`,
86+
`getTokenEndpointAuthMethod()`) return the **raw registered value** — an empty array
87+
/ `null` when the client has nothing registered — rather than synthesizing the OIDC
88+
DCR spec default. This keeps the stored value the single source of truth, so:
89+
90+
- the admin UI shows exactly what is registered (a pre-DCR client shows these fields
91+
as unset, not as phantom defaults), and saving such a client does not silently
92+
impose constraints;
93+
- the registration response still echoes the spec defaults, because for dynamic
94+
registrations those defaults are persisted at registration time (in
95+
`ClientEntityFactory`), not invented at read time.
96+
97+
A future major version may move the spec defaults into the getters themselves (so an
98+
unset value resolves to the spec default everywhere). v7 deliberately does not, to
99+
give deployments a transition window in which to set explicit values on clients that
100+
predate these properties.
79101

80102
## Implementation order
81103

src/Entities/ClientEntity.php

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@
2323
use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
2424
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
2525
use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum;
26-
use SimpleSAML\OpenID\Codebooks\GrantTypesEnum;
2726
use SimpleSAML\OpenID\Codebooks\ResponseModesEnum;
28-
use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum;
29-
use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum;
3027

3128
class ClientEntity implements ClientEntityInterface
3229
{
@@ -539,8 +536,14 @@ public function getRequestUris(): array
539536
}
540537

541538
/**
542-
* The OAuth 2.0 grant types the client is registered to use. Defaults to ["authorization_code"]
543-
* (OpenID Connect Dynamic Client Registration 1.0 default) when not explicitly registered.
539+
* The OAuth 2.0 grant types the client is registered to use, or an empty array when none are registered.
540+
*
541+
* v7 transition: this returns the raw registered value (empty when unset) rather than synthesizing the
542+
* OpenID Connect Dynamic Client Registration 1.0 default (["authorization_code"]). This keeps the stored
543+
* value the single source of truth, so the admin UI and the registration echo reflect exactly what is
544+
* registered, and clients created before this property existed are not retroactively constrained. The DCR
545+
* default is still applied at registration time for dynamic clients (see ClientEntityFactory). A future
546+
* major version may switch this getter to return the spec default when unset.
544547
*
545548
* @return string[]
546549
*/
@@ -551,15 +554,17 @@ public function getGrantTypes(): array
551554
($this->extraMetadata[ClaimsEnum::GrantTypes->value] ?? null) : null;
552555

553556
if (!is_array($grantTypes)) {
554-
return [GrantTypesEnum::AuthorizationCode->value];
557+
return [];
555558
}
556559

557560
return array_values(array_filter($grantTypes, 'is_string'));
558561
}
559562

560563
/**
561-
* The OAuth 2.0 response types the client is registered to use. Defaults to ["code"]
562-
* (OpenID Connect Dynamic Client Registration 1.0 default) when not explicitly registered.
564+
* The OAuth 2.0 response types the client is registered to use, or an empty array when none are registered.
565+
*
566+
* v7 transition: returns the raw registered value (empty when unset) rather than synthesizing the OpenID
567+
* Connect Dynamic Client Registration 1.0 default (["code"]). See getGrantTypes() for the rationale.
563568
*
564569
* @return string[]
565570
*/
@@ -570,18 +575,21 @@ public function getResponseTypes(): array
570575
($this->extraMetadata[ClaimsEnum::ResponseTypes->value] ?? null) : null;
571576

572577
if (!is_array($responseTypes)) {
573-
return [ResponseTypesEnum::Code->value];
578+
return [];
574579
}
575580

576581
return array_values(array_filter($responseTypes, 'is_string'));
577582
}
578583

579584
/**
580-
* The client authentication method the client is registered to use at the token endpoint. Defaults to
581-
* 'client_secret_basic' for confidential clients and 'none' for public clients when not explicitly registered
582-
* (OpenID Connect Dynamic Client Registration 1.0).
585+
* The client authentication method the client is registered to use at the token endpoint, or null when none
586+
* is registered.
587+
*
588+
* v7 transition: returns the raw registered value (null when unset) rather than synthesizing the OpenID
589+
* Connect Dynamic Client Registration 1.0 default ('client_secret_basic' / 'none'). See getGrantTypes() for
590+
* the rationale.
583591
*/
584-
public function getTokenEndpointAuthMethod(): string
592+
public function getTokenEndpointAuthMethod(): ?string
585593
{
586594
/** @var mixed $method */
587595
$method = is_array($this->extraMetadata) ?
@@ -591,9 +599,7 @@ public function getTokenEndpointAuthMethod(): string
591599
return $method;
592600
}
593601

594-
return $this->isConfidential() ?
595-
TokenEndpointAuthMethodsEnum::ClientSecretBasic->value :
596-
TokenEndpointAuthMethodsEnum::None->value;
602+
return null;
597603
}
598604

599605
/**
@@ -604,7 +610,7 @@ public function getDefaultMaxAge(): ?int
604610
{
605611
/** @var mixed $value */
606612
$value = is_array($this->extraMetadata) ?
607-
($this->extraMetadata[ClaimsEnum::DefaultMaxAge->value] ?? null) : null;
613+
($this->extraMetadata[ClaimsEnum::DefaultMaxAge->value] ?? null) : null;
608614

609615
if (is_int($value) || is_string($value)) {
610616
$filtered = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
@@ -621,7 +627,7 @@ public function getRequireAuthTime(): bool
621627
{
622628
/** @var mixed $value */
623629
$value = is_array($this->extraMetadata) ?
624-
($this->extraMetadata[ClaimsEnum::RequireAuthTime->value] ?? null) : null;
630+
($this->extraMetadata[ClaimsEnum::RequireAuthTime->value] ?? null) : null;
625631

626632
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
627633
}

src/Entities/Interfaces/ClientEntityInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public function getGrantTypes(): array;
102102
*/
103103
public function getResponseTypes(): array;
104104

105-
public function getTokenEndpointAuthMethod(): string;
105+
public function getTokenEndpointAuthMethod(): ?string;
106106

107107
public function getDefaultMaxAge(): ?int;
108108

src/Server/Grants/AuthCodeGrant.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -528,15 +528,17 @@ public function respondToAccessTokenRequest(
528528
// it is predefined as the ClientRule result and authenticated against by ClientAuthenticationRule above.
529529
$client = $authorizationClientEntity;
530530

531-
// Per-client grant_types enforcement: if the client explicitly registered grant_types, it must include
532-
// 'authorization_code' to exchange a code here. Enforced only when explicitly registered, preserving
533-
// behavior for manually-managed clients that do not have it configured. The refresh_token grant is
534-
// intentionally NOT gated on grant_types (see RefreshTokenGrant): a refresh token is only issued when
535-
// offline_access was granted and consented, which is itself the authorization to refresh.
531+
// Per-client grant_types enforcement: if the client explicitly registered a non-empty grant_types list, it
532+
// must include 'authorization_code' to exchange a code here. Enforced only when explicitly registered
533+
// (present and non-empty), preserving behavior for manually-managed and pre-DCR clients that do not have it
534+
// configured - or have it as an empty list. The refresh_token grant is intentionally NOT gated on
535+
// grant_types (see RefreshTokenGrant): a refresh token is only issued when offline_access was granted and
536+
// consented, which is itself the authorization to refresh.
536537
/** @var mixed $registeredGrantTypes */
537538
$registeredGrantTypes = $client->getExtraMetadata()[ClaimsEnum::GrantTypes->value] ?? null;
538539
if (
539540
is_array($registeredGrantTypes) &&
541+
$registeredGrantTypes !== [] &&
540542
!in_array(GrantTypesEnum::AuthorizationCode->value, $registeredGrantTypes, true)
541543
) {
542544
throw OidcServerException::unauthorizedClient(

src/Server/RequestRules/Rules/ResponseTypeRule.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,21 @@ public function checkRule(
5757

5858
$responseType = (string)$requestParams[ParamsEnum::ResponseType->value];
5959

60-
// Per-client enforcement: if the client has explicitly registered response_types, the requested
61-
// response_type must be one of them. We enforce only when the value was explicitly registered (present in
62-
// the client's metadata); clients that do not have it configured are not constrained, preserving behavior
63-
// for manually-managed clients. Dynamically registered clients always have it (the OIDC DCR default is
64-
// applied at registration).
60+
// Per-client enforcement: if the client has explicitly registered a non-empty response_types list, the
61+
// requested response_type must be one of them. We enforce only when the value was explicitly registered
62+
// (present and non-empty in the client's metadata); clients that do not have it configured - or have it as
63+
// an empty list - are not constrained, preserving behavior for manually-managed and pre-DCR clients.
64+
// Dynamically registered clients always have it (the OIDC DCR default is applied at registration).
6565
$client = $currentResultBag->getOrFail(ClientRule::class)->getValue();
6666
/** @var mixed $registeredResponseTypes */
6767
$registeredResponseTypes = ($client instanceof ClientEntityInterface) ?
6868
($client->getExtraMetadata()[ClaimsEnum::ResponseTypes->value] ?? null) : null;
6969

70-
if (is_array($registeredResponseTypes) && !in_array($responseType, $registeredResponseTypes, true)) {
70+
if (
71+
is_array($registeredResponseTypes) &&
72+
$registeredResponseTypes !== [] &&
73+
!in_array($responseType, $registeredResponseTypes, true)
74+
) {
7175
$loggerService->error(
7276
'ResponseTypeRule: response_type not registered for client.',
7377
['response_type' => $responseType, 'registered' => $registeredResponseTypes],

tests/unit/src/Entities/ClientEntityTest.php

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,9 @@ public function testCanExportAsArray(): void
230230
'require_pushed_authorization_requests' => false,
231231
'require_signed_request_object' => false,
232232
'request_uris' => [],
233-
'grant_types' => [
234-
'authorization_code',
235-
],
236-
'response_types' => [
237-
'code',
238-
],
239-
'token_endpoint_auth_method' => 'none',
233+
'grant_types' => [],
234+
'response_types' => [],
235+
'token_endpoint_auth_method' => null,
240236
'default_max_age' => null,
241237
'require_auth_time' => false,
242238
'default_acr_values' => [],
@@ -298,4 +294,50 @@ public function testCanGetAuthProcFilters(): void
298294
$this->assertSame($authProcFilters, $clientEntity->getAuthProcFilters());
299295
$this->assertSame($authProcFilters, $clientEntity->toArray()[ClientEntity::KEY_AUTH_PROC_FILTERS]);
300296
}
297+
298+
public function testEnforcementGettersReturnRawRegisteredValues(): void
299+
{
300+
// v7 transition: when not registered, these getters return the raw "unset" value (empty / null) rather
301+
// than synthesizing the OIDC DCR spec defaults, so the stored value stays the single source of truth and
302+
// pre-DCR clients are not retroactively constrained.
303+
$unset = $this->mock();
304+
$this->assertSame([], $unset->getGrantTypes());
305+
$this->assertSame([], $unset->getResponseTypes());
306+
$this->assertNull($unset->getTokenEndpointAuthMethod());
307+
308+
// When registered, the stored values are returned.
309+
$registered = new ClientEntity(
310+
$this->id,
311+
$this->secret,
312+
$this->name,
313+
$this->description,
314+
$this->redirectUri,
315+
$this->scopes,
316+
$this->isEnabled,
317+
$this->isConfidential,
318+
$this->authSource,
319+
$this->owner,
320+
$this->postLogoutRedirectUri,
321+
$this->backChannelLogoutUri,
322+
$this->entityIdentifier,
323+
$this->clientRegistrationTypes,
324+
$this->federationJwks,
325+
$this->jwks,
326+
$this->jwksUri,
327+
$this->signedJwksUri,
328+
$this->registrationType,
329+
$this->updatedAt,
330+
$this->createdAt,
331+
$this->expiresAt,
332+
$this->isGeneric,
333+
[
334+
'grant_types' => ['authorization_code', 'refresh_token'],
335+
'response_types' => ['code'],
336+
'token_endpoint_auth_method' => 'private_key_jwt',
337+
],
338+
);
339+
$this->assertSame(['authorization_code', 'refresh_token'], $registered->getGrantTypes());
340+
$this->assertSame(['code'], $registered->getResponseTypes());
341+
$this->assertSame('private_key_jwt', $registered->getTokenEndpointAuthMethod());
342+
}
301343
}

tests/unit/src/Server/RequestRules/Rules/ResponseTypeRuleTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,30 @@ public function testRejectsResponseTypeNotRegisteredForClient(): void
138138
);
139139
}
140140

141+
public function testEmptyRegisteredResponseTypesIsNotEnforced(): void
142+
{
143+
// A present-but-empty response_types list means "not configured / unconstrained", not "allow nothing".
144+
// This preserves behavior for pre-DCR clients that get an empty list persisted on an admin save.
145+
$client = $this->createStub(ClientEntityInterface::class);
146+
$client->method('getExtraMetadata')->willReturn(['response_types' => []]);
147+
148+
$bag = new ResultBag();
149+
$bag->add(new Result(ClientRule::class, $client));
150+
151+
$this->requestParams['response_type'] = 'id_token';
152+
$this->requestParamsResolverStub->method('getAllBasedOnAllowedMethods')->willReturn($this->requestParams);
153+
154+
$result = $this->sut()->checkRule(
155+
$this->requestStub,
156+
$bag,
157+
$this->loggerServiceStub,
158+
[],
159+
$this->responseModeStub,
160+
);
161+
162+
$this->assertSame('id_token', $result?->getValue());
163+
}
164+
141165
public function testResponseTypeRuleThrowsWithNoResponseTypeParamTest()
142166
{
143167
$params = $this->requestParams;

0 commit comments

Comments
 (0)