Skip to content

Commit 49128de

Browse files
committed
WIP
1 parent 3c5014b commit 49128de

29 files changed

Lines changed: 1092 additions & 27 deletions

config/module_oidc.php.dist

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

123+
/**
124+
* Pushed Authorization Request (PAR) and Request Object URL (JAR) configurations.
125+
*/
126+
ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // PAR request URI expiration TTL (default: 10 minutes)
127+
ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, // Require PAR globally (default: false)
128+
ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, // Reject unsigned request objects globally (default: false)
129+
ModuleConfig::OPTION_REQUEST_URI_TIMEOUT => 5, // Timeout for fetching request_uri (default: 5 seconds)
130+
ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, // Maximum allowed response size for request_uri in bytes (default: 100KB)
131+
123132
/**
124133
* The default authentication source to be used for authentication if the
125134
* authentication source is not specified on a particular client.

hooks/hook_cron.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository;
2020
use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository;
2121
use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository;
22+
use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository;
2223
use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository;
2324
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
2425
use SimpleSAML\Module\oidc\Services\Container;
@@ -69,6 +70,10 @@ function oidc_hook_cron(array &$croninfo): void
6970
$issuerStateRepository = $container->get(IssuerStateRepository::class);
7071
$issuerStateRepository->removeInvalid();
7172

73+
/** @var \SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository $pushedAuthRepo */
74+
$pushedAuthRepo = $container->get(PushedAuthorizationRequestRepository::class);
75+
$pushedAuthRepo->deleteExpired(new DateTimeImmutable());
76+
7277
$croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.';
7378
} catch (Exception $e) {
7479
$message = 'Module `oidc` clean up cron script failed: ' . $e->getMessage();

routing/routes/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use SimpleSAML\Module\oidc\Controllers\JwksController;
2121
use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController;
2222
use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController;
23+
use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController;
2324
use SimpleSAML\Module\oidc\Controllers\UserInfoController;
2425
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController;
2526
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController;
@@ -111,6 +112,10 @@
111112
$routes->add(RoutesEnum::OAuth2Configuration->name, RoutesEnum::OAuth2Configuration->value)
112113
->controller(OAuth2ServerConfigurationController::class);
113114

115+
$routes->add(RoutesEnum::PushedAuthorizationRequest->name, RoutesEnum::PushedAuthorizationRequest->value)
116+
->controller([PushedAuthorizationController::class, 'par'])
117+
->methods([HttpMethodsEnum::POST->value]);
118+
114119
/*****************************************************************************************************************
115120
* OpenID Federation
116121
****************************************************************************************************************/

src/Codebooks/RoutesEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ enum RoutesEnum: string
4949

5050
// OAuth 2.0 Authorization Server Metadata https://www.rfc-editor.org/rfc/rfc8414.html
5151
case OAuth2Configuration = '.well-known/oauth-authorization-server';
52+
case PushedAuthorizationRequest = 'par';
5253

5354
/*****************************************************************************************************************
5455
* OpenID Federation

src/Controllers/Admin/ClientController.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,17 @@ protected function buildClientEntityFromFormData(
354354
$data[ClaimsEnum::IdTokenSignedResponseAlg->value] :
355355
null;
356356

357+
$requirePushedAuth = (bool)($data['require_pushed_authorization_requests'] ?? false);
358+
$requireSignedReqObj = (bool)($data['require_signed_request_object'] ?? false);
359+
/** @var mixed $rawRequestUris */
360+
$rawRequestUris = $data['request_uris'] ?? null;
361+
$requestUris = is_array($rawRequestUris) ? $rawRequestUris : [];
362+
357363
$extraMetadata = [
358364
ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg,
365+
'require_pushed_authorization_requests' => $requirePushedAuth,
366+
'require_signed_request_object' => $requireSignedReqObj,
367+
'request_uris' => $requestUris,
359368
];
360369

361370
$allowedResponseModes = is_array($data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES]) ?
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the simplesamlphp-module-oidc.
7+
*
8+
* Copyright (C) 2026 by the Spanish Research and Academic Network.
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace SimpleSAML\Module\oidc\Controllers;
15+
16+
use League\OAuth2\Server\Exception\OAuthServerException;
17+
use Psr\Http\Message\ResponseInterface;
18+
use Psr\Http\Message\ServerRequestInterface;
19+
use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge;
20+
use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity;
21+
use SimpleSAML\Module\oidc\Helpers;
22+
use SimpleSAML\Module\oidc\ModuleConfig;
23+
use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository;
24+
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
25+
use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager;
26+
use SimpleSAML\Module\oidc\Server\RequestRules\Result;
27+
use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag;
28+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule;
29+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule;
30+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule;
31+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule;
32+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule;
33+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule;
34+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule;
35+
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule;
36+
use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode;
37+
use SimpleSAML\Module\oidc\Services\ErrorResponder;
38+
use SimpleSAML\Module\oidc\Services\LoggerService;
39+
use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver;
40+
use SimpleSAML\Module\oidc\Utils\JwksResolver;
41+
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
42+
use SimpleSAML\OpenID\Core;
43+
use Symfony\Component\HttpFoundation\Request;
44+
use Symfony\Component\HttpFoundation\Response;
45+
46+
class PushedAuthorizationController
47+
{
48+
public function __construct(
49+
private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver,
50+
private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository,
51+
private readonly RequestRulesManager $requestRulesManager,
52+
private readonly JwksResolver $jwksResolver,
53+
private readonly Core $core,
54+
private readonly ModuleConfig $moduleConfig,
55+
private readonly PsrHttpBridge $psrHttpBridge,
56+
private readonly ErrorResponder $errorResponder,
57+
private readonly Helpers $helpers,
58+
private readonly LoggerService $logger,
59+
) {
60+
}
61+
62+
public function __invoke(ServerRequestInterface $request): ResponseInterface
63+
{
64+
$this->logger->debug('PushedAuthorizationController::__invoke');
65+
66+
if (strtoupper($request->getMethod()) !== 'POST') {
67+
return $this->psrHttpBridge->getResponseFactory()->createResponse()
68+
->withStatus(405)
69+
->withHeader('Allow', 'POST');
70+
}
71+
72+
// 1. Authenticate client
73+
$resolvedAuth = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request);
74+
if (is_null($resolvedAuth)) {
75+
throw OidcServerException::accessDenied('Client authentication failed');
76+
}
77+
78+
$client = $resolvedAuth->getClient();
79+
80+
if ($resolvedAuth->getClientAuthenticationMethod()->isNone() && $client->isConfidential()) {
81+
throw OidcServerException::accessDenied('Confidential client must authenticate.');
82+
}
83+
84+
// 2. Parse request params
85+
$bodyParams = $request->getParsedBody();
86+
$params = is_array($bodyParams) ? $bodyParams : [];
87+
88+
// 3. Reject request_uri in PAR body
89+
if (isset($params['request_uri'])) {
90+
throw OidcServerException::invalidRequest(
91+
'request_uri',
92+
'The request_uri parameter MUST NOT be provided in pushed authorization requests.',
93+
);
94+
}
95+
96+
// 4. Handle JAR in PAR (request parameter)
97+
if (isset($params['request'])) {
98+
try {
99+
$requestObject = $this->core->jarRequestObjectFactory()->fromToken((string)$params['request']);
100+
$jwks = $this->jwksResolver->forClient($client);
101+
if (is_null($jwks)) {
102+
throw OidcServerException::invalidRequest(
103+
'request',
104+
'Client JWKS not available for signature verification.',
105+
);
106+
}
107+
$requestObject->verifyWithKeySet($jwks);
108+
109+
if ($requestObject->getClientId() !== $client->getIdentifier()) {
110+
throw OidcServerException::invalidRequest(
111+
'request',
112+
'Client ID in request object does not match authenticated client.',
113+
);
114+
}
115+
116+
$params = array_merge($params, $requestObject->getPayload());
117+
unset($params['request']);
118+
} catch (\Throwable $t) {
119+
throw OidcServerException::invalidRequest('request', 'Invalid request object: ' . $t->getMessage());
120+
}
121+
}
122+
123+
// 5. Build mock request with merged params and run validation rules
124+
$psrRequest = $request->withParsedBody($params)->withQueryParams([]);
125+
126+
$resultBag = new ResultBag();
127+
$resultBag->add(new Result(ClientRule::class, $client));
128+
129+
$this->requestRulesManager->predefineResultBag($resultBag);
130+
131+
$rulesToExecute = [
132+
StateRule::class,
133+
ClientRedirectUriRule::class,
134+
ResponseModeRule::class,
135+
ScopeRule::class,
136+
RequiredOpenIdScopeRule::class,
137+
CodeChallengeRule::class,
138+
CodeChallengeMethodRule::class,
139+
];
140+
141+
$this->requestRulesManager->setData('default_scope', '');
142+
$this->requestRulesManager->setData('scope_delimiter_string', ' ');
143+
144+
$this->requestRulesManager->check(
145+
$psrRequest,
146+
$rulesToExecute,
147+
new QueryResponseMode(),
148+
[HttpMethodsEnum::POST],
149+
);
150+
151+
// 6. Generate request_uri
152+
$hex = bin2hex(random_bytes(32));
153+
$requestUri = 'urn:ietf:params:oauth:request_uri:' . $hex;
154+
155+
// 7. Persist entity
156+
$ttl = $this->moduleConfig->getParRequestUriTtl();
157+
$expiresAt = $this->helpers->dateTime()->getUtc()->add($ttl);
158+
159+
// Make sure we carry forward all validated params
160+
$entity = new PushedAuthorizationRequestEntity(
161+
requestUri: $requestUri,
162+
clientId: $client->getIdentifier(),
163+
parameters: $params,
164+
expiresAt: \DateTimeImmutable::createFromInterface($expiresAt),
165+
isConsumed: false,
166+
);
167+
168+
$this->pushedAuthorizationRequestRepository->persist($entity);
169+
170+
// 8. Respond
171+
$expiresIn = $this->helpers->dateTime()->getSecondsToExpirationTime($expiresAt->getTimestamp());
172+
$responseBody = json_encode([
173+
'request_uri' => $requestUri,
174+
'expires_in' => $expiresIn,
175+
], JSON_THROW_ON_ERROR);
176+
177+
$response = $this->psrHttpBridge->getResponseFactory()->createResponse()
178+
->withStatus(201)
179+
->withHeader('Cache-Control', 'no-cache, no-store')
180+
->withHeader('Content-Type', 'application/json');
181+
182+
$response->getBody()->write($responseBody);
183+
184+
return $response;
185+
}
186+
187+
public function par(Request $request): Response
188+
{
189+
try {
190+
$psrRequest = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request);
191+
$psrResponse = $this->__invoke($psrRequest);
192+
return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse);
193+
} catch (OAuthServerException $exception) {
194+
return $this->errorResponder->forException($exception);
195+
} catch (\Throwable $exception) {
196+
return $this->errorResponder->forException(
197+
OidcServerException::invalidRequest('request', $exception->getMessage()),
198+
);
199+
}
200+
}
201+
}

src/Entities/ClientEntity.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ public function toArray(): array
232232

233233
// Extra metadata
234234
ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(),
235+
'require_pushed_authorization_requests' => $this->getRequirePushedAuthorizationRequests(),
236+
'require_signed_request_object' => $this->getRequireSignedRequestObject(),
237+
'request_uris' => $this->getRequestUris(),
235238
];
236239
}
237240

@@ -406,4 +409,48 @@ public function getAllowedResponseModes(): array
406409
ResponseModesEnum::FormPost->value,
407410
];
408411
}
412+
413+
public function getRequirePushedAuthorizationRequests(): bool
414+
{
415+
if (!is_array($this->extraMetadata)) {
416+
return false;
417+
}
418+
419+
return (bool)($this->extraMetadata['require_pushed_authorization_requests'] ?? false);
420+
}
421+
422+
public function getRequireSignedRequestObject(): bool
423+
{
424+
if (!is_array($this->extraMetadata)) {
425+
return false;
426+
}
427+
428+
return (bool)($this->extraMetadata['require_signed_request_object'] ?? false);
429+
}
430+
431+
/**
432+
* @return string[]
433+
*/
434+
public function getRequestUris(): array
435+
{
436+
if (!is_array($this->extraMetadata)) {
437+
return [];
438+
}
439+
440+
/** @var mixed $uris */
441+
$uris = $this->extraMetadata['request_uris'] ?? null;
442+
if (!is_array($uris)) {
443+
return [];
444+
}
445+
446+
$stringUris = [];
447+
/** @var mixed $uri */
448+
foreach ($uris as $uri) {
449+
if (is_string($uri)) {
450+
$stringUris[] = $uri;
451+
}
452+
}
453+
454+
return $stringUris;
455+
}
409456
}

src/Entities/Interfaces/ClientEntityInterface.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,10 @@ public function isGeneric(): bool;
8383
public function getExtraMetadata(): array;
8484
public function getIdTokenSignedResponseAlg(): ?string;
8585
public function getAllowedResponseModes(): array;
86+
public function getRequirePushedAuthorizationRequests(): bool;
87+
public function getRequireSignedRequestObject(): bool;
88+
/**
89+
* @return string[]
90+
*/
91+
public function getRequestUris(): array;
8692
}

0 commit comments

Comments
 (0)