Skip to content

Commit 278e641

Browse files
committed
Redeem a pushed authorization request with its pushed parameters only
A PAR request_uri used at the authorization endpoint was array_merged over the parameters sent with it: a pushed parameter won, but one the pushed request did not carry was taken from the front channel, so the user agent could add prompt, max_age, acr_values, claims, nonce, response_mode, state, code_challenge, dpop_jkt, issuer_state, authorization_details and others to a pushed request. RFC 9126 section 4 has the request built as RFC 9101 defines, whose sections 5 and 6.3 have the authorization server use only the request's own parameters, even where the client repeats them in the query. - RequestParamsResolver redeems a PAR request_uri with the pushed parameters, plus the client_id (the pushed one wins, RequestUriRule refuses another client) and the request_uri sent with it, whatever else the request carries. Every other front-channel parameter is ignored, whether or not PAR is required. RequestUriRule logs at notice the names of ignored parameters the pushed request does not carry. Request Objects by value and by https request_uri keep the OpenID Connect Core merge. - The token and end session endpoints mark the requests they hand on (RequestParamsResolver::ATTRIBUTE_OWN_PARAMS_ONLY), whose parameters are then read as they were sent: a request or request_uri parameter there is ignored (RFC 6749 section 3.2), where a Request Object's claims used to be merged over them since v6. - The authproc state gets the authorization request as the grants validate it (the query of a GET, the body of a POST). - The PAR endpoint persists what its rules validated: with a Request Object, the form body merged with the Request Object claims (which win), rather than the payload alone, which dropped a validated form parameter. A push without response_type is refused with invalid_request, since nothing can supply it at redemption. - Docs: the PAR endpoint section and the plain OAuth 2.0 notes in docs/3, the v7 PAR entry and a low-impact note in docs/6, and the RFC 9126 compliance matrix (rows 5, 12, 16 and 17 updated, row 23 added).
1 parent e59faf3 commit 278e641

15 files changed

Lines changed: 525 additions & 104 deletions

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ When you change PAR / request_uri behaviour, keep this table in sync.
2424
|---|------------------------|-------------|-----------------|
2525
| 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`. |
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`. |
27-
| 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`). | `PushedAuthorizationControllerTest::testHandlesValidParRequest` (and the rule-specific tests for each rule). |
27+
| 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

2929
## Successful response (RFC 9126 §2.2)
3030

@@ -46,7 +46,7 @@ When you change PAR / request_uri behaviour, keep this table in sync.
4646

4747
| # | Requirement (spec ref) | Enforced in | Covered by test |
4848
|---|------------------------|-------------|-----------------|
49-
| 12 | When a Request Object is used, all authorization request parameters MUST appear as claims of the JWT; only the (validated) payload is used (§3-1). | `PushedAuthorizationController::resolveParametersToPersist()` persists the Request Object payload only (drops body params) when JAR is used. | `PushedAuthorizationControllerTest::testPersistsRequestObjectPayloadOnlyWhenJarIsUsed`. |
49+
| 12 | When a Request Object is used, all authorization request parameters MUST appear as claims of the JWT (§3-1; a requirement on the client). | Lenient: the rules validate the Request Object together with the form body, its claims superseding form parameters of the same name (`RequestParamsResolver`), and `PushedAuthorizationController::resolveParametersToPersist()` persists that set, without the client authentication parameters and `request`. A compliant client is unaffected; one which leaves parameters in the form body is redeemed with what was validated (the redemption reads nothing else, row 23). | `PushedAuthorizationControllerTest`: `testPersistsTheFormBodyWithTheRequestObjectClaimsSupersedingItWhenJarIsUsed`, `testPersistsTheFormBodyScopeWithARequestObjectWhichLeavesItOut`. |
5050
| 13 | MUST validate the Request Object signature (§3 step 2). | `RequestObjectRule::verifySignature()` (JWKS via `JwksResolver`). | `RequestObjectRuleTest`: `testThrowsForInvalidRequestObject`, `testReturnsValidRequestObject`, `testReturnsValidJarRequestObjectForOAuth2Request`, `testMissingClientJwksThrows`. |
5151
| 14 | If the client has credentials, MUST reject when the authenticated `client_id` does not match the `client_id` claim in the Request Object (§3 step 3). | `PushedAuthorizationController::resolveParametersToPersist()` (claim vs authenticated client); `RequestObjectRule` (JAR `client_id` claim vs client). | `PushedAuthorizationControllerTest::testRejectsRequestObjectClientIdClaimWhichDoesNotMatchAuthenticatedClient`; `RequestObjectRuleTest::testThrowsForOAuth2RequestWithMismatchedClientIdClaim`. |
5252

@@ -55,9 +55,10 @@ When you change PAR / request_uri behaviour, keep this table in sync.
5555
| # | Requirement (spec ref) | Enforced in | Covered by test |
5656
|---|------------------------|-------------|-----------------|
5757
| 15 | An expired `request_uri` MUST be rejected (§4-3). | `RequestUriRule` checks `isExpired()` (UTC); `PushedAuthorizationRequestRepository::findValid()` also filters expired. | `RequestUriRuleTest::testThrowsIfPushedAuthorizationRequestIsExpired`; `PushedAuthorizationRequestRepositoryTest::testFindValidReturnsNullForExpiredRequestUri`. |
58-
| 16 | `request_uri` is treated as one-time use (§4-3 SHOULD; implemented strictly). | `RequestUriRule` consumes the `request_uri` atomically at validation time; `PushedAuthorizationRequestRepository::consume()` is an atomic `UPDATE … WHERE is_consumed = 0` (replay guard). | `RequestUriRuleTest`: `testThrowsIfPushedAuthorizationRequestIsConsumed`, `testThrowsIfPushedAuthorizationRequestConsumptionFails`, `testCanUseValidPushedAuthorizationRequestUri`; `PushedAuthorizationRequestRepositoryTest`: `testConsumeReturnsTrueOnlyOnce`, `testFindValidReturnsNullForConsumedRequestUri`, `testConsumeInvalidatesCache`. |
59-
| 17 | The AS MUST validate authorization requests arising from a pushed request as it would any other (§4-4). | `RequestUriRule` resolves the pushed params into the merged request view, after which the standard authorization rule pipeline runs. | `RequestUriRuleTest::testCanUseValidPushedAuthorizationRequestUri` (+ the full rule pipeline). |
58+
| 16 | `request_uri` is treated as one-time use (§4-3 SHOULD; implemented strictly). | `RequestUriRule` refuses a consumed `request_uri`; `AuthorizationServer::completeAuthorizationRequest()` consumes it when the authorization response is issued (the user agent comes back with it after the login, so it can not be consumed at validation), refusing the response if it was consumed meanwhile; `PushedAuthorizationRequestRepository::consume()` is an atomic `UPDATE … WHERE is_consumed = 0` (replay guard). | `RequestUriRuleTest`: `testThrowsIfPushedAuthorizationRequestIsConsumed`, `testCanUseValidPushedAuthorizationRequestUriWithoutConsumingIt`; `AuthorizationServerTest`: `testConsumesThePushedRequestBeforeTheResponseIsIssued`, `testRefusesToCompleteAPushedRequestWhichIsAlreadyConsumed`; `PushedAuthorizationRequestRepositoryTest`: `testConsumeReturnsTrueOnlyOnce`, `testFindValidReturnsNullForConsumedRequestUri`, `testConsumeInvalidatesCache`. |
59+
| 17 | The AS MUST validate authorization requests arising from a pushed request as it would any other (§4-4). | `RequestParamsResolver` gives the rules the pushed params as the request's (row 23), after which the standard authorization rule pipeline runs on them. | `RequestUriRuleTest::testCanUseValidPushedAuthorizationRequestUriWithoutConsumingIt` (+ the full rule pipeline). |
6060
| 18 | If policy requires PAR (global or per-client), the AS MUST refuse, with `invalid_request`, any authorization request without a PAR `request_uri` (§4-5). | `RequestUriRule` PAR-required check (`getRequirePushedAuthorizationRequests()` module + client). | `RequestUriRuleTest`: `testThrowsIfParIsRequiredGloballyButNotUsed`, `testThrowsIfParIsRequiredForClientButNotUsed`. |
61+
| 23 | The client builds the authorization request with the `request_uri` as RFC 9101 defines (§4-1), and the AS MUST only use the parameters of the request, even where the client repeats them in the query (RFC 9101 §5, §6.3). | `RequestParamsResolver::resolveParams()` redeems a PAR `request_uri` with the pushed params only; of the front-channel params it keeps `client_id` (the pushed one supersedes it, and `RequestUriRule` refuses one naming another client) and `request_uri`, and ignores every other one, repeated or not. `RequestUriRule` logs at notice the names of ignored params the pushed request does not carry. A `request_uri` sent with `request` is redeemed the same way, whatever the `request` value (null included), and `RequestUriRule` refuses the two together. The token and end session endpoints mark their requests (`RequestParamsResolver::ATTRIBUTE_OWN_PARAMS_ONLY`), so a `request_uri` sent there is ignored rather than redeemed (RFC 6749 §3.2). | `RequestParamsResolverTest`: `testRedeemsAPushedAuthorizationRequestUriWithThePushedParamsOnly`, `testIgnoresParamsSentWithAPushedRequestUriWhichThePushedRequestLacks`, `testAPushedClientIdSupersedesTheOneSentWithTheRequestUri`, `testGetAllResolvesOnlyTheClientIdAndRequestUriForInvalidPushedAuthorizationRequestUri`, `testRedeemsAPushedRequestUriWithThePushedParamsOnlyNextToARequestParam`, `testReadsTheOwnParamsOnlyOfARequestMarkedSoWhateverRequestUriItCarries`; `AccessTokenControllerTest::testHandsARequestWithoutAProofToTheGrantsReadAsItWasSent`; `EndSessionControllerTest::testValidatesTheLogoutRequestReadAsItWasSent`; `RequestUriRuleTest`: `testLogsTheNamesOfParamsSentWithAPushedRequestUriWhichThePushedRequestLacks`, `testLogsNothingForParamsThePushedRequestRepeats`. |
6162

6263
## Authorization server metadata (RFC 9126 §5)
6364

‎docs/3-oidc-configuration.md‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,10 +621,10 @@ It differs in what OpenID Connect alone defines:
621621
is not demanded of the login either (`acr_values` is, see below).
622622
- A Request Object it comes in has to be a signed JAR one (RFC 9101) whose
623623
`client_id` claim names the client, as for any request without `openid`.
624-
- Pushed to the PAR endpoint without a `scope`, it is kept with an empty one,
625-
so a `scope` added in the browser when the `request_uri` is used can not
626-
turn it into an OpenID Connect request. (Every pushed request is kept with
627-
the scope it was validated with.)
624+
- Pushed to the PAR endpoint, it is used with the parameters it was pushed
625+
with only, so a `scope` added in the browser when the `request_uri` is used
626+
can not turn it into an OpenID Connect request (see
627+
[The PAR endpoint](#the-par-endpoint)).
628628
- The client gets no back-channel logout token on account of such a login:
629629
without an ID token it has no session with the OP to be told the end of
630630
(OpenID Connect Back-Channel Logout 1.0).
@@ -666,6 +666,14 @@ A client can send authorization request parameters in several ways:
666666
- **Remote `https://` request_uri** — the OP fetches the Request Object
667667
from the given URL (JAR by reference, or OpenID Federation by reference).
668668

669+
A Request Object passed by value or by a remote `request_uri` is combined with
670+
the plain parameters sent with it, its claims taking precedence over
671+
parameters of the same name (OpenID Connect Core). A pushed request is not
672+
combined with anything, see below. The token and end session endpoints take
673+
neither: a `request` or `request_uri` parameter sent to them is ignored, as any
674+
parameter they do not recognize is (RFC 6749 section 3.2 for the token
675+
endpoint).
676+
669677
### The PAR endpoint
670678

671679
The PAR endpoint is published in the discovery document as
@@ -678,6 +686,33 @@ It authenticates the client the same way as the token endpoint (including
678686
JSON response with the generated `request_uri` and an `expires_in` value. Errors
679687
are returned as JSON (token-endpoint style); the endpoint never redirects.
680688

689+
A pushed request is used as it was pushed. When the client sends the user
690+
agent to the authorization endpoint with the `request_uri`, the OP takes the
691+
request from the pushed parameters only (RFC 9126 section 4 has the request
692+
built as RFC 9101 defines, whose sections 5 and 6.3 have the server use only
693+
the parameters of the Request Object). Of the parameters sent along, only
694+
`client_id`, which has to name the client that pushed the request, and the
695+
`request_uri` itself are read. Any other one is ignored, also one the pushed
696+
request does not carry, so nothing can be added to a pushed request on its way
697+
through the browser. A client may still repeat parameters there (RFC 9101
698+
section 5 allows it); the pushed values are the ones used. The names (never the
699+
values) of ignored parameters which the pushed request does not carry are
700+
logged at the notice level, since a client may have meant to add them this way.
701+
702+
What is stored is therefore all there is to the request:
703+
704+
- A pushed request without `response_type` is refused with `invalid_request`,
705+
since nothing could supply it later.
706+
- A pushed request is stored with the scope it was validated with, an empty
707+
one if it named none.
708+
- A pushed request which carries a Request Object (`request`) is validated
709+
together with the other parameters of the form body, the Request Object's
710+
claims taking precedence over form parameters of the same name, and that is
711+
what is stored, without the client authentication parameters. RFC 9126
712+
section 3 has a client put every authorization request parameter in the
713+
Request Object; a client which leaves some in the form body still gets them
714+
used, as they were validated.
715+
681716
### Request Object flavors
682717

683718
When a Request Object is provided (by value or by reference), the OP detects

‎docs/6-oidc-upgrade.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ endpoint (`pushed_authorization_request_endpoint`, served at
6464
`<basepath>/module.php/oidc/par`) lets clients push authorization request
6565
parameters in a back-channel `POST` and receive a one-time, short-lived
6666
`request_uri` (`urn:ietf:params:oauth:request_uri:...`) to use at the
67-
authorization endpoint. It can be required globally or per client.
67+
authorization endpoint, which then takes the request from the pushed
68+
parameters only: of the parameters sent along with the `request_uri`, only
69+
`client_id` is read (RFC 9126 section 4, RFC 9101). It can be required
70+
globally or per client.
6871
- Support for passing a Request Object by reference using the `request_uri`
6972
parameter (in addition to the existing by-value `request` support), covering
7073
both JWT-Secured Authorization Request (JAR, RFC 9101) by reference and OpenID
@@ -825,6 +828,13 @@ Connect Core 1.0 section 5.3). Before the new
825828
refresh request which narrowed the scopes to leave `openid` out; it used to get
826829
the claims of its remaining scopes. A token of an OpenID4VCI flow is answered
827830
as before.
831+
- The token endpoint and the end session endpoint now read a request's
832+
parameters as it sends them: a `request` parameter (a Request Object) in one
833+
is ignored, as a parameter they do not recognize is (RFC 6749 section 3.2 for
834+
the token endpoint),
835+
where its claims used to be read as parameters of the request, superseding
836+
those of the same name. Only the authorization endpoint and the PAR endpoint
837+
take a Request Object.
828838
- The underlying `thephpleague/oauth2-server` library has been updated from
829839
v8 to v9
830840
- The requirements on packages `lcobucci/jwt` and `web-token/jwt-framework`

‎src/Controllers/AccessTokenController.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
1515
use SimpleSAML\Module\oidc\Server\Validators\DpopProofVerifier;
1616
use SimpleSAML\Module\oidc\Services\ErrorResponder;
17+
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
1718
use SimpleSAML\Module\oidc\Utils\Routes;
1819
use Symfony\Component\HttpFoundation\Request;
1920
use Symfony\Component\HttpFoundation\Response;
@@ -55,6 +56,10 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
5556
$request = $request->withAttribute(DpopProofVerifier::ATTRIBUTE_VERIFIED_PROOF, $verifiedDpopProof);
5657
}
5758

59+
// A token request is read as it was sent: a request or request_uri param in it is ignored, rather than read
60+
// as an authorization request's (RFC 6749 section 3.2).
61+
$request = $request->withAttribute(RequestParamsResolver::ATTRIBUTE_OWN_PARAMS_ONLY, true);
62+
5863
return $this->authorizationServer->respondToAccessTokenRequest(
5964
$request,
6065
$this->psrHttpBridge->getResponseFactory()->createResponse(),

‎src/Controllers/EndSessionController.php‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use SimpleSAML\Module\oidc\Services\LoggerService;
1616
use SimpleSAML\Module\oidc\Services\SessionService;
1717
use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder;
18+
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
1819
use SimpleSAML\Module\oidc\Utils\UiLocalesResolver;
1920
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
2021
use SimpleSAML\Session;
@@ -52,7 +53,11 @@ public function __invoke(ServerRequestInterface $request): Response
5253
// only category the spec asks us to revoke is never created here, and the offline_access ones are
5354
// intentionally left intact. No token revocation is therefore required during logout.
5455

55-
$logoutRequest = $this->authorizationServer->validateLogoutRequest($request);
56+
// A logout request is read as it was sent: a request or request_uri param in it is ignored, rather than read
57+
// as an authorization request's.
58+
$logoutRequest = $this->authorizationServer->validateLogoutRequest(
59+
$request->withAttribute(RequestParamsResolver::ATTRIBUTE_OWN_PARAMS_ONLY, true),
60+
);
5661

5762
$uiLanguage = $this->resolveUiLanguage($logoutRequest);
5863

0 commit comments

Comments
 (0)