Skip to content

Commit a845bed

Browse files
committed
Ignore unsupported scope values, answer token errors with RFC 6749 codes, and state the granted scope
Unknown scope values are now ignored at the authorization and PAR endpoints instead of failing the request with invalid_scope (OpenID4VCI 1.0 section 5.1.2, OpenID Connect Core 1.0 section 3.1.2.1, RFC 6749 section 3.3); ScopeRule logs the values it ignored. The id of a credential configuration which can be requested through authorization_details only is ignored as a scope the same way. A credential issuance request left naming no credential configuration, by scope or by authorization_details, is refused with invalid_scope (OfferedCredentialsRule), with or without an offer. The token endpoint answers with the error codes of RFC 6749 section 5.2: invalid_grant for a code which can not be decrypted or is malformed, was issued to another client, or comes with another redirect_uri; invalid_client (401, with League's challenge when the Authorization header was used) where client authentication is missing or fails, at the PAR endpoint too (RFC 9126 section 2.3). The caller is authenticated from its own credentials and then compared with the code's client, rather than authenticated against that client, so a client disabled or expired since its code was issued can no longer redeem it; the redirect_uri is compared, as sent, with the authorization request's alone, no longer held to the registration again. Token responses, and implicit responses which return an access token, state the granted scopes in scope (RFC 6749 sections 5.1 and 4.2.2), every time, leaving out the ids of authorization-details-only configurations. ErrorResponder keeps every OAuth error_description, in a JSON body or a redirect, to the characters RFC 6749 allows, as Routes already did for the VCI endpoints, and passes League's invalid_client challenge on in JSON error responses. Upgrade notes for the OpenID Connect visible changes; the OpenID4VCI compliance matrix closes G1 and G23 (rows 12 and 20).
1 parent 6cc86fe commit a845bed

27 files changed

Lines changed: 991 additions & 219 deletions

‎config/module_oidc.php.dist‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ $config = [
175175
* either: OpenID Connect Core section 11 defines it as a request for a
176176
* refresh token, so there is nothing to grant. It is then left out of
177177
* 'scopes_supported', a client can not be registered with it, and a
178-
* request carrying it is refused as 'invalid_scope' like any other
179-
* unsupported scope.
178+
* request carrying it is served without it, the scope value being ignored
179+
* like any other unsupported one.
180180
*
181181
* If not set, every grant type is enabled.
182182
*/

‎conformance-tests/openid4vci-1_0-compliance.md‎

Lines changed: 4 additions & 8 deletions
Large diffs are not rendered by default.

‎conformance-tests/rfc9126-par-compliance.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ When you change PAR / request_uri behaviour, keep this table in sync.
2222

2323
| # | Requirement (spec ref) | Enforced in | Covered by test |
2424
|---|------------------------|-------------|-----------------|
25-
| 3 | Authenticate the client the same way as at the token endpoint (§2.1, step 1). | `PushedAuthorizationController::__invoke()` via `AuthenticatedOAuth2ClientResolver::forAnySupportedMethod()`; confidential clients must authenticate. | `PushedAuthorizationControllerTest`: `testClientAuthenticationFailureThrows`, `testConfidentialClientMustAuthenticate`. |
25+
| 3 | Authenticate the client the same way as at the token endpoint (§2.1, step 1). | `PushedAuthorizationController::__invoke()` via `AuthenticatedOAuth2ClientResolver::forAnySupportedMethod()`; confidential clients must authenticate. A client which fails to, or a confidential one which presents no authentication, is refused with `invalid_client` as at the token endpoint (§2.3, RFC 6749 section 5.2), with the challenge of its scheme when it used the Authorization header (`ErrorResponder::forExceptionJson()`). | `PushedAuthorizationControllerTest`: `testClientAuthenticationFailureThrows`, `testConfidentialClientMustAuthenticate`. |
2626
| 4 | MUST reject the request if the `request_uri` parameter is provided (§2.1 step 2; §2.1-1 "MUST NOT be provided"). | `PushedAuthorizationController::__invoke()` rejects `request_uri` in the body. | `PushedAuthorizationControllerTest::testRejectsRequestUriInBody`. |
2727
| 5 | MUST validate the pushed request as it would an authorization request at the authorization endpoint (§2.1 step 3). | `PushedAuthorizationController::__invoke()` runs the authorization request-rule pipeline (`StateRule`, `ClientRedirectUriRule`, `RequestObjectRule`, `ResponseModeRule`, `ScopeRule`, `RequiredOpenIdScopeRule`, `CodeChallengeRule`, `CodeChallengeMethodRule`, `DpopJktRule`, `IssuerStateRule`, `AuthorizationDetailsRule`, `OfferedCredentialsRule`); `resolveParametersToPersist()` refuses a request without `response_type`, which nothing could supply at redemption (row 23). | `PushedAuthorizationControllerTest::testHandlesValidParRequest`, `testRefusesAPushedRequestWithoutAResponseType` (and the rule-specific tests for each rule). |
2828

‎docs/1-oidc.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ is refused unless `plain_oauth2_authorization_code_enabled` turns such requests
2424
on; it then gets no ID token. See
2525
[Configuration](3-oidc-configuration.md#plain-oauth-20-authorization-code-requests).
2626

27+
Scope values the OP does not support are ignored rather than refused (OpenID
28+
Connect Core 1.0 section 3.1.2.1), and the OP log names them. The token
29+
response states the scopes the access token grants in `scope` (RFC 6749 section
30+
5.1), so a client learns which of those it asked for it got.
31+
2732
Authorization request parameters can be sent as plain parameters, by value as a
2833
Request Object (`request`, OpenID Connect Core / JAR), or by reference
2934
(`request_uri`) — either via Pushed Authorization Requests (PAR, RFC 9126) or a
@@ -219,6 +224,10 @@ Currently implemented OpenID4VCI features:
219224
of its configuration, and the token response leaves `claims` out of the
220225
details it returns. A credential configuration whose metadata states no
221226
`scope` can be requested through authorization details only.
227+
- Scopes: a scope value the issuer does not support is ignored (OpenID4VCI
228+
1.0 section 5.1.2), and a credential request which is then left naming no
229+
credential configuration, by `scope` or by authorization details, is refused
230+
with `invalid_scope`.
222231
- Supported Credential Formats
223232
- JWT VC JSON (`jwt_vc_json`): W3C VCDM v1.1 Verifiable Credentials encoded as
224233
JWT.

‎docs/3-oidc-configuration.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -589,9 +589,10 @@ OpenID Connect Discovery 1.0 section 3 has list the standard scopes "if
589589
supported"), from the scopes a client can be registered with (Dynamic Client
590590
Registration filters it out, the client form does not offer it, and the default
591591
set for a scope-less dynamic registration no longer contains it), and an
592-
authorization request carrying it is refused as `invalid_scope`, the way the
593-
module answers any scope it does not support. A client which relies on refresh
594-
tokens should therefore not be registered with such an OP.
592+
authorization request carrying it is served without it, the way the module
593+
treats any scope value it does not support (it ignores the value and logs it).
594+
A client which relies on refresh tokens should therefore not be registered with
595+
such an OP.
595596

596597
Why disable the implicit grant: OAuth 2.0 Security Best Current Practice
597598
(RFC 9700, section 2.1.2) has clients avoid the implicit grant and every

‎docs/6-oidc-upgrade.md‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ deployment behaves as before; a grant type taken off the list is neither
130130
advertised, nor registrable, nor served, even for a client registered with it
131131
earlier. Without `refresh_token` the `offline_access` scope is not supported
132132
either: it leaves `scopes_supported` and the registrable scopes, and a request
133-
for it is refused as `invalid_scope`. (Related tightening: a Verifiable
133+
for it is served without it (see the item on unsupported scope values below).
134+
(Related tightening: a Verifiable
134135
Credential configuration id equal to a standard OpenID Connect scope name —
135136
`openid`, `offline_access`, `profile`, `email`, `address`, `phone` — is now
136137
refused while issuance is enabled, since such an id becomes a scope which would
@@ -734,6 +735,47 @@ answers any unexpected failure while processing a request in the token error
734735
format (`{"error": "server_error", ...}`, HTTP 500, with the cause in the OP
735736
log rather than in the response) instead of with SimpleSAMLphp's HTML error
736737
page.
738+
- The token endpoint now refuses requests with the error codes of RFC 6749
739+
section 5.2. An authorization code which can not be decrypted or is malformed,
740+
one issued to another client, and a `redirect_uri` which differs from the
741+
authorization request's get `invalid_grant` (HTTP 400). They got
742+
`invalid_request`, or `access_denied` (HTTP 401) where a client which
743+
authenticated as itself presented another client's code. The `redirect_uri`
744+
is now compared with the authorization request's alone, as RFC 6749 section
745+
4.1.3 has it, and no longer with the client's registration as well. A client
746+
which presents no client
747+
authentication it can be authenticated by, or a confidential client which
748+
presents none (the `none` method), gets `invalid_client` (HTTP 401, with a
749+
`WWW-Authenticate: Basic realm="OAuth"` challenge when it sent an
750+
`Authorization` header), where it got `access_denied` (HTTP 401); the PAR
751+
endpoint now refuses such a client the same way (RFC 9126 section 2.3). The
752+
client is authenticated from its own credentials, rather than taken from the
753+
code it presents, so a client which has been disabled, or has expired, since
754+
its code was issued can no longer redeem it. A client which compares the
755+
`error` member with the old values needs the new ones.
756+
- Scope values the OP does not support are now ignored at the authorization and
757+
PAR endpoints, rather than refused: the request goes on with the scopes it
758+
asked for which the OP supports, and the OP log names the ones it ignored
759+
(`notice` level). Previously any one of them failed the request with
760+
`invalid_scope`. OpenID Connect Core 1.0 (section 3.1.2.1) has an OpenID
761+
Provider ignore scope values it does not understand, and RFC 6749 (section
762+
3.3) lets an authorization server grant fewer scopes than were requested. A
763+
request left without `openid` is still refused (`invalid_request`), unless it
764+
is a plain OAuth 2.0 request the deployment allows. A client with a mistyped
765+
scope therefore now gets a token without that scope instead of an error, and
766+
the token response says which scopes it got (next item). The `scope` of a
767+
refresh token request is checked as before (RFC 6749 section 6).
768+
- Token responses now carry `scope`, the scopes the access token grants, and so
769+
does an implicit response which returns an access token (`id_token token`).
770+
RFC 6749 (sections 5.1 and 4.2.2) requires it whenever the scopes granted
771+
differ from those requested, as they do when a scope value was ignored or the
772+
client is not registered for a scope it asked for; the OP states it every
773+
time. A client which does not read the member is unaffected.
774+
- The `error_description` of every OAuth error response, in a JSON body or sent
775+
back to the redirect URI, now keeps only the characters RFC 6749 allows in it
776+
(sections 4.1.2.1, 4.2.2.1 and 5.2: printable ASCII except the double quote and
777+
the backslash); any other is dropped. Previously a description could carry
778+
them, a double quote quoting a value, for instance.
737779
- The reuse checks of `private_key_jwt` client assertions (in the protocol
738780
cache) and of OpenID Federation Request Objects (in the federation cache) now
739781
fail the request with `server_error` when the cache does not store the record

‎src/Controllers/PushedAuthorizationController.php‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,21 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
7777
->withHeader('Allow', HttpMethodsEnum::POST->value);
7878
}
7979

80-
// Authenticate the client in the same way as at the token endpoint.
80+
// Authenticate the client in the same way as at the token endpoint, and refuse it the same way:
81+
// `invalid_client` (RFC 9126 section 2.3, RFC 6749 section 5.2).
8182
$resolvedAuth = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request);
8283
if (is_null($resolvedAuth)) {
83-
throw OidcServerException::accessDenied('Client authentication failed.');
84+
throw OidcServerException::invalidClient($request);
8485
}
8586

8687
$client = $resolvedAuth->getClient();
8788

8889
if ($resolvedAuth->getClientAuthenticationMethod()->isNone() && $client->isConfidential()) {
89-
throw OidcServerException::accessDenied('Confidential client must authenticate.');
90+
$this->logger->warning(
91+
'Pushed authorization request rejected: confidential client used the "none" authentication method.',
92+
['client_id' => $client->getIdentifier()],
93+
);
94+
throw OidcServerException::invalidClient($request);
9095
}
9196

9297
$bodyParams = $request->getParsedBody();

‎src/Factories/TokenResponseFactory.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public function build(): TokenResponse
3030
$this->idTokenBuilder,
3131
$this->privateKey,
3232
$this->loggerService,
33+
$this->moduleConfig,
3334
);
3435
$tokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey());
3536

‎src/ModuleConfig.php‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,10 +1218,10 @@ public function getForcedAcrValueForCookieAuthentication(): ?string
12181218
*
12191219
* OpenID Connect Core 1.0 section 11 defines 'offline_access' as a request for a refresh token, so without
12201220
* the refresh token grant (see OPTION_ENABLED_GRANT_TYPES) the scope has nothing to grant and is left out: it
1221-
* is not advertised, a client can not be registered with it, and a request carrying it is refused as
1222-
* 'invalid_scope' like any other unsupported scope. The name is taken out of the merged set, and a private
1223-
* scope (validate()) or Verifiable Credential configuration (getVciScopes()) named after a standard scope is
1224-
* refused, so nothing merged later can put it back.
1221+
* is not advertised, a client can not be registered with it, and a request carrying it is served without
1222+
* it, the value being ignored like any other unsupported one (ScopeRule). The name is taken out of the merged
1223+
* set, and a private scope (validate()) or Verifiable Credential configuration (getVciScopes()) named after a
1224+
* standard scope is refused, so nothing merged later can put it back.
12251225
*
12261226
* @throws \Exception
12271227
*/

‎src/Server/Exceptions/OidcServerException.php‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ public static function unsupportedResponseType(
119119
* @param string|null $redirectUri An HTTP URI to redirect the user back to
120120
* @param string|null $state
121121
* @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface|null $responseMode
122+
* @param string|null $hint What is wrong, in place of the hint derived from the scope
122123
* @return static
123124
* @psalm-suppress LessSpecificImplementedReturnType
124125
*/
@@ -127,15 +128,11 @@ public static function invalidScope(
127128
?string $redirectUri = null,
128129
?string $state = null,
129130
?ResponseModeInterface $responseMode = null,
131+
?string $hint = null,
130132
): static {
131-
if (empty($scope)) {
132-
$hint = 'Specify a scope in the request or set a default scope';
133-
} else {
134-
$hint = sprintf(
135-
'Check the `%s` scope',
136-
htmlspecialchars($scope, ENT_QUOTES, 'UTF-8', false),
137-
);
138-
}
133+
$hint ??= empty($scope) ?
134+
'Specify a scope in the request or set a default scope' :
135+
sprintf('Check the `%s` scope', htmlspecialchars($scope, ENT_QUOTES, 'UTF-8', false));
139136

140137
$e = self::create(
141138
'The requested scope is invalid, unknown, or malformed',

0 commit comments

Comments
 (0)