Skip to content

Commit 54716ec

Browse files
authored
Add support for PAR & request_uri (#339)
1 parent 3c5014b commit 54716ec

46 files changed

Lines changed: 3762 additions & 91 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"psr/container": "^2.0",
3232
"psr/log": "^3",
3333
"simplesamlphp/composer-module-installer": "^1.3",
34-
"simplesamlphp/openid": "~0.2.3",
34+
"simplesamlphp/openid": "~v0.3.0",
3535
"spomky-labs/base64url": "^2.0",
3636
"symfony/expression-language": "^7.4",
3737
"symfony/psr-http-message-bridge": "^7.4",

config/module_oidc.php.dist

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,72 @@ $config = [
120120
*/
121121
ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M',
122122

123+
/**
124+
* Pushed Authorization Request (PAR) request URI expiration TTL.
125+
* This is the time for which a PAR request URI will be valid.
126+
*
127+
* For duration format info, check
128+
* https://www.php.net/manual/en/dateinterval.construct.php
129+
*
130+
* Default: PT10M (10 minutes)
131+
*/
132+
ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // 10 minutes
133+
134+
/**
135+
* Require Pushed Authorization Request (PAR) globally. If set to true,
136+
* the OP will reject authorization requests which do not use PAR, i.e.,
137+
* requests which do not have a valid request_uri corresponding
138+
* to a previously pushed authorization request.
139+
*
140+
* Default: false
141+
*/
142+
ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false,
143+
144+
/**
145+
* Whether to support passing the Request Object by reference using the
146+
* https request_uri parameter (JAR / OpenID Federation) by reference.
147+
* Note that the client must have its request URIs registered beforehand.
148+
* For OpenID Federation Automatic Registration, see the option
149+
* OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES.
150+
*
151+
* Set to `false` to remove any risk of DoS / SSRF attacks by disabling
152+
* outbound fetches to given request URIs. Note that this does not
153+
* affect Pushed Authorization Request URIs (urn form), which are
154+
* always supported.
155+
*
156+
* Default: true
157+
*/
158+
ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => true,
159+
160+
/**
161+
* Timeout for fetching request_uri, in seconds.
162+
*
163+
* Default: 5 seconds
164+
*/
165+
ModuleConfig::OPTION_REQUEST_URI_FETCH_TIMEOUT => 5,
166+
167+
/**
168+
* Maximum allowed response size for request_uri, in bytes.
169+
*
170+
* Default: 102400 bytes (100KB)
171+
*/
172+
ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400,
173+
174+
/**
175+
* Enforces signature validation for all Request Objects. OpenID Connect
176+
* Core allows Request Objects to be unsigned, but when this option is
177+
* set to true, the OP will reject any Request Object that does not
178+
* contain a valid signature.
179+
*
180+
* Note: in order for this to work, all Relying Parties must support
181+
* signing Request Objects, and the OP must have their corresponding
182+
* signing keys configured. Of course, this is only relevant if they
183+
* use Request Object in authorization requests.
184+
*
185+
* Default: false
186+
*/
187+
ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false,
188+
123189
/**
124190
* The default authentication source to be used for authentication if the
125191
* authentication source is not specified on a particular client.
@@ -649,6 +715,25 @@ $config = [
649715
ModuleConfig::OPTION_INFORMATION_URI => null,
650716
ModuleConfig::OPTION_ORGANIZATION_URI => null,
651717

718+
/**
719+
* Allowlist for fetching the Request Object by reference for OpenID
720+
* Federation candidates (clients not yet registered in storage).
721+
* Registered (non-federation) clients are not affected with
722+
* this option (for them the request_uri must match their
723+
* registered request_uris exactly).
724+
*
725+
* Examples:
726+
* - [] (empty array, the default): deny all federation-candidate fetches,
727+
* - ['https://rp.example.org/', ...]: allow only request_uris starting with one of the given prefixes,
728+
* - null: explicitly allow any request_uri for federation candidates (not recommended).
729+
*
730+
* Note: prefixes should be specific enough to avoid false positives
731+
* (e.g. https://rp.example.org/ with the trailing slash). A loose
732+
* prefix like 'https://' would defeat the purpose, at which point
733+
* `null` is the clearer way to say "allow any."
734+
*/
735+
ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [],
736+
652737

653738
/***************************************************************************
654739
* (optional) OpenID Verifiable Credential related options. If these are
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# RFC 9126 (PAR) + request_uri — MUST compliance checklist
2+
3+
The OpenID Foundation conformance suite only exercises PAR as part of the
4+
FAPI 2.0 profile (which imposes many unrelated requirements), so it is not a
5+
practical fit for validating PAR on this general-purpose OP. Instead, this
6+
document tracks every normative (MUST / MUST NOT / REQUIRED) requirement from
7+
[RFC 9126](https://www.rfc-editor.org/rfc/rfc9126) — plus the directly related
8+
[RFC 9101 (JAR)](https://www.rfc-editor.org/rfc/rfc9101) request-object
9+
processing rules for the `request` / `request_uri` paths — and maps each to the
10+
code that enforces it and the unit test(s) that prove it.
11+
12+
When you change PAR / request_uri behaviour, keep this table in sync.
13+
14+
## Pushed Authorization Request endpoint (RFC 9126 §2)
15+
16+
| # | Requirement (spec ref) | Enforced in | Covered by test |
17+
|---|------------------------|-------------|-----------------|
18+
| 1 | PAR endpoint URL MUST use the `https` scheme (§2). | Endpoint URL is derived from the module/issuer base URL; HTTPS is a deployment concern. | _Deployment-level_ (issuer/base URL must be HTTPS); not unit-tested. |
19+
| 2 | The AS MUST accept its issuer identifier, token endpoint URL, **or PAR endpoint URL** as the client-assertion audience (§2). | `AuthenticatedOAuth2ClientResolver::forPrivateKeyJwt()` (PAR endpoint URL added to expected audiences). | `AuthenticatedOAuth2ClientResolverTest`: `testForPrivateKeyJwtAcceptsPushedAuthorizationRequestEndpointAsAudience`, `testForPrivateKeyJwtAcceptsIssuerIdentifierAsAudience`, `testForPrivateKeyJwtReturnsResolvedResultOnSuccess` (token endpoint), `testForPrivateKeyJwtThrowsWhenAudienceClaimDoesNotContainExpectedValue`. |
20+
21+
## Request processing (RFC 9126 §2.1)
22+
23+
| # | Requirement (spec ref) | Enforced in | Covered by test |
24+
|---|------------------------|-------------|-----------------|
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`. |
26+
| 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). |
28+
29+
## Successful response (RFC 9126 §2.2)
30+
31+
| # | Requirement (spec ref) | Enforced in | Covered by test |
32+
|---|------------------------|-------------|-----------------|
33+
| 6 | On success the server MUST generate a `request_uri` and return it with HTTP `201` (and `expires_in`). | `PushedAuthorizationController::__invoke()` (201, JSON body with `request_uri` + `expires_in`, `Cache-Control: no-cache, no-store`). | `PushedAuthorizationControllerTest::testHandlesValidParRequest`. |
34+
| 7 | The `request_uri` MUST contain a cryptographically strong pseudorandom part (§2.2; §7.1). | `PushedAuthorizationRequestEntityFactory::fromData()` uses `bin2hex(random_bytes(32))` behind the `urn:ietf:params:oauth:request_uri:` prefix. | `PushedAuthorizationRequestEntityFactoryTest`: `testCanBuildNew` (prefix + 64 hex chars), `testBuildNewGeneratesUniqueRequestUris`. |
35+
| 8 | The `request_uri` value MUST be bound to the client that posted it (§2.2-4). | Entity stores `client_id`; `RequestUriRule` checks the bound client at the authorization endpoint; the controller binds `client_id` to the authenticated client on persist. | `RequestUriRuleTest::testThrowsIfPushedAuthorizationRequestIsBoundToDifferentClient`; `PushedAuthorizationControllerTest::testHandlesValidParRequest` (client_id bound). |
36+
37+
## Error response (RFC 9126 §2.3)
38+
39+
| # | Requirement (spec ref) | Enforced in | Covered by test |
40+
|---|------------------------|-------------|-----------------|
41+
| 9 | Errors MUST use the token-endpoint error format (JSON); the endpoint MUST NOT redirect (§2.3). | `PushedAuthorizationController::par()` catches exceptions → `ErrorResponder::forExceptionJson()` (JSON, never redirects). | `PushedAuthorizationControllerTest`: `testParReturnsJsonErrorResponseForOAuthServerException`, `testParReturnsGenericJsonErrorResponseForUnexpectedThrowable` (also asserts internal details are not leaked). |
42+
| 10 | If signed Request Objects are required (by AS or client policy), the AS MUST only accept §3-compliant requests and MUST refuse others with 400 `invalid_request` (§2.3-2). | `RequestObjectRule` enforces `require_signed_request_object` (module + client); run at the PAR endpoint via the controller pipeline. | `RequestObjectRuleTest`: `testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled`, `testThrowsWhenClientRequireSignedRequestObjectIsEnabled`. |
43+
| 11 | If the request did not use `POST`, respond with HTTP `405` (§2.3). | `PushedAuthorizationController::__invoke()` 405 guard with `Allow: POST`. | `PushedAuthorizationControllerTest::testMethodMustBePost`. |
44+
45+
## The `request` parameter (RFC 9126 §3 — JAR processing)
46+
47+
| # | Requirement (spec ref) | Enforced in | Covered by test |
48+
|---|------------------------|-------------|-----------------|
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`. |
50+
| 13 | MUST validate the Request Object signature (§3 step 2). | `RequestObjectRule::verifySignature()` (JWKS via `JwksResolver`). | `RequestObjectRuleTest`: `testThrowsForInvalidRequestObject`, `testReturnsValidRequestObject`, `testReturnsValidJarRequestObjectForOAuth2Request`, `testMissingClientJwksThrows`. |
51+
| 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`. |
52+
53+
## Authorization request using the `request_uri` (RFC 9126 §4)
54+
55+
| # | Requirement (spec ref) | Enforced in | Covered by test |
56+
|---|------------------------|-------------|-----------------|
57+
| 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). |
60+
| 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+
62+
## Authorization server metadata (RFC 9126 §5)
63+
64+
| # | Requirement (spec ref) | Enforced in | Covered by test |
65+
|---|------------------------|-------------|-----------------|
66+
| 19 | Publish `pushed_authorization_request_endpoint` and `require_pushed_authorization_requests` (§5; §2-2 SHOULD). Also `request_uri_parameter_supported` and `require_request_uri_registration` (OIDC Discovery). | `OpMetadataService` populates all four. | `OpMetadataServiceTest` (asserts `pushed_authorization_request_endpoint`, `require_pushed_authorization_requests`, `request_uri_parameter_supported`, `require_request_uri_registration`). |
67+
68+
## Security considerations (RFC 9126 §7)
69+
70+
| # | Requirement (spec ref) | Enforced in | Covered by test |
71+
|---|------------------------|-------------|-----------------|
72+
| 20 | The AS MUST only accept new (unregistered) redirect URIs from authenticated clients (§7.2, open redirection). | The module never accepts unregistered redirect URIs: `ClientRedirectUriRule` always exact-matches the client's registered redirect URIs, and the PAR endpoint always authenticates the client. Compliant by being strict (the §2.4 per-request redirect-URI relaxation is intentionally **not** implemented). | `ClientRedirectUriRuleTest` (redirect URI exact matching). |
73+
74+
## Related request-object claim validation (RFC 9101 / RFC 7519)
75+
76+
Not strictly RFC 9126, but part of the same `request` / `request_uri` feature:
77+
78+
| # | Requirement | Enforced in | Covered by test |
79+
|---|-------------|-------------|-----------------|
80+
| 21 | A JAR (non-OIDC) Request Object MUST be signed; an unsigned object is rejected (RFC 9101). | `RequestObjectRule` requires a `Jar\RequestObject` from the parsed bag for non-OIDC (no `openid` scope) requests. | `RequestObjectRuleTest::testThrowsForOAuth2RequestWithNonJarRequestObject`. |
81+
| 22 | When present, the Request Object `aud` claim must include this OP's issuer, and `iss` must equal the client (RFC 9101 §4, RFC 7519; "validate-if-present"). | `RequestObjectRule::verifyAudience()` / `verifyIssuer()` (OIDC Core + JAR flavors). | `RequestObjectRuleTest`: `testAcceptsOidcRequestWhenAudienceIncludesIssuer`, `testThrowsForOidcRequestWhenAudienceDoesNotIncludeIssuer`, `testThrowsForOAuth2RequestWhenAudienceDoesNotIncludeIssuer`, `testAcceptsOidcRequestWhenIssuerMatchesClient`, `testThrowsForOidcRequestWhenIssuerDoesNotMatchClient`, `testThrowsForOAuth2RequestWhenIssuerDoesNotMatchClient`. |
82+
83+
## Intentionally not implemented (RFC 9126 MAY / optional)
84+
85+
These are optional and deliberately left out; revisit if requirements change:
86+
87+
- **413 Payload Too Large** for oversized PAR bodies (§2.3) — MAY. (Note:
88+
`request_uri_max_size_bytes` caps the *outbound* remote `request_uri` fetch,
89+
not the inbound PAR body.)
90+
- **429 Too Many Requests** rate limiting on the PAR endpoint (§2.3) — MAY.
91+
- **Per-request unregistered `redirect_uri`** for authenticated clients
92+
(§2.4) — MAY relaxation; the module keeps strict exact-match instead.

0 commit comments

Comments
 (0)