Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"psr/container": "^2.0",
"psr/log": "^3",
"simplesamlphp/composer-module-installer": "^1.3",
"simplesamlphp/openid": "~0.2.3",
"simplesamlphp/openid": "~v0.3.0",
"spomky-labs/base64url": "^2.0",
"symfony/expression-language": "^7.4",
"symfony/psr-http-message-bridge": "^7.4",
Expand Down
9 changes: 9 additions & 0 deletions config/module_oidc.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ $config = [
*/
ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M',

/**
* Pushed Authorization Request (PAR) and Request Object URL (JAR) configurations.
*/
ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // PAR request URI expiration TTL (default: 10 minutes)
ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, // Require PAR globally (default: false)
ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, // Reject unsigned request objects globally (default: false)
ModuleConfig::OPTION_REQUEST_URI_TIMEOUT => 5, // Timeout for fetching request_uri (default: 5 seconds)
ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, // Maximum allowed response size for request_uri in bytes (default: 100KB)

/**
* The default authentication source to be used for authentication if the
* authentication source is not specified on a particular client.
Expand Down
5 changes: 5 additions & 0 deletions hooks/hook_cron.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository;
use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository;
use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository;
use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository;
use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository;
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
use SimpleSAML\Module\oidc\Services\Container;
Expand Down Expand Up @@ -69,6 +70,10 @@ function oidc_hook_cron(array &$croninfo): void
$issuerStateRepository = $container->get(IssuerStateRepository::class);
$issuerStateRepository->removeInvalid();

/** @var \SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository $parRepository */
$parRepository = $container->get(PushedAuthorizationRequestRepository::class);
$parRepository->removeExpired();

$croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.';
} catch (Exception $e) {
$message = 'Module `oidc` clean up cron script failed: ' . $e->getMessage();
Expand Down
5 changes: 5 additions & 0 deletions routing/routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use SimpleSAML\Module\oidc\Controllers\JwksController;
use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController;
use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController;
use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController;
use SimpleSAML\Module\oidc\Controllers\UserInfoController;
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController;
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController;
Expand Down Expand Up @@ -111,6 +112,10 @@
$routes->add(RoutesEnum::OAuth2Configuration->name, RoutesEnum::OAuth2Configuration->value)
->controller(OAuth2ServerConfigurationController::class);

$routes->add(RoutesEnum::PushedAuthorizationRequest->name, RoutesEnum::PushedAuthorizationRequest->value)
->controller([PushedAuthorizationController::class, 'par'])
->methods([HttpMethodsEnum::POST->value]);

/*****************************************************************************************************************
* OpenID Federation
****************************************************************************************************************/
Expand Down
2 changes: 2 additions & 0 deletions routing/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ services:
SimpleSAML\OpenID\Did: ~
SimpleSAML\OpenID\Jws:
factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ]
SimpleSAML\OpenID\RequestObject:
factory: [ '@SimpleSAML\Module\oidc\Factories\RequestObjectFactory', 'build' ]


# SSP
Expand Down
1 change: 1 addition & 0 deletions src/Codebooks/RoutesEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ enum RoutesEnum: string

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

/*****************************************************************************************************************
* OpenID Federation
Expand Down
9 changes: 9 additions & 0 deletions src/Controllers/Admin/ClientController.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,17 @@ protected function buildClientEntityFromFormData(
$data[ClaimsEnum::IdTokenSignedResponseAlg->value] :
null;

$requirePushedAuth = (bool)($data[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false);
$requireSignedReqObj = (bool)($data[ClaimsEnum::RequireSignedRequestObject->value] ?? false);
/** @var mixed $rawRequestUris */
$rawRequestUris = $data[ClaimsEnum::RequestUris->value] ?? null;
$requestUris = is_array($rawRequestUris) ? $rawRequestUris : [];

$extraMetadata = [
ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg,
ClaimsEnum::RequirePushedAuthorizationRequests->value => $requirePushedAuth,
ClaimsEnum::RequireSignedRequestObject->value => $requireSignedReqObj,
ClaimsEnum::RequestUris->value => $requestUris,
];

$allowedResponseModes = is_array($data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES]) ?
Expand Down
236 changes: 236 additions & 0 deletions src/Controllers/PushedAuthorizationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<?php

declare(strict_types=1);

/*
* This file is part of the simplesamlphp-module-oidc.
*
* Copyright (C) 2026 by the Spanish Research and Academic Network.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SimpleSAML\Module\oidc\Controllers;

use League\OAuth2\Server\Exception\OAuthServerException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge;
use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory;
use SimpleSAML\Module\oidc\Helpers;
use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository;
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface;
use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager;
use SimpleSAML\Module\oidc\Server\RequestRules\Result;
use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule;
use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode;
use SimpleSAML\Module\oidc\Services\ErrorResponder;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver;
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
use SimpleSAML\OpenID\Codebooks\ParamsEnum;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class PushedAuthorizationController
{
public function __construct(
private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver,
private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository,
private readonly PushedAuthorizationRequestEntityFactory $pushedAuthorizationRequestEntityFactory,
private readonly RequestRulesManager $requestRulesManager,
private readonly PsrHttpBridge $psrHttpBridge,
private readonly ErrorResponder $errorResponder,
private readonly Helpers $helpers,
private readonly LoggerService $logger,
) {
}

/**
* @throws \League\OAuth2\Server\Exception\OAuthServerException
* @throws \Throwable
*/
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
$this->logger->debug('PushedAuthorizationController::__invoke');

if (strtoupper($request->getMethod()) !== HttpMethodsEnum::POST->value) {
return $this->psrHttpBridge->getResponseFactory()->createResponse()
->withStatus(405)
->withHeader('Allow', HttpMethodsEnum::POST->value);
}

// Authenticate the client in the same way as at the token endpoint.
$resolvedAuth = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request);
if (is_null($resolvedAuth)) {
throw OidcServerException::accessDenied('Client authentication failed.');
}

$client = $resolvedAuth->getClient();

if ($resolvedAuth->getClientAuthenticationMethod()->isNone() && $client->isConfidential()) {
throw OidcServerException::accessDenied('Confidential client must authenticate.');
}

$bodyParams = $request->getParsedBody();
$bodyParams = is_array($bodyParams) ? $bodyParams : [];

// The request_uri authorization request parameter must not be used in pushed authorization requests.
if (array_key_exists(ParamsEnum::RequestUri->value, $bodyParams)) {
throw OidcServerException::invalidRequest(
ParamsEnum::RequestUri->value,
'The request_uri parameter must not be used in pushed authorization requests.',
);
}

// Validate the pushed params as we would an authorization request sent to the authorization endpoint.
// Note that the rules transparently take the Request Object (request param) into account, with
// RequestObjectRule doing its validation (signature, signed-required policy...).
$resultBag = new ResultBag();
$resultBag->add(new Result(ClientRule::class, $client));
$this->requestRulesManager->predefineResultBag($resultBag);

$this->requestRulesManager->setData('default_scope', '');
$this->requestRulesManager->setData('scope_delimiter_string', ' ');

$rulesToExecute = [
StateRule::class,
ClientRedirectUriRule::class,
RequestObjectRule::class,
ResponseModeRule::class,
ScopeRule::class,
RequiredOpenIdScopeRule::class,
CodeChallengeRule::class,
CodeChallengeMethodRule::class,
];

$resultBag = $this->requestRulesManager->check(
$request,
$rulesToExecute,
new QueryResponseMode(),
[HttpMethodsEnum::POST],
);

$parameters = $this->resolveParametersToPersist($resultBag, $bodyParams, $client->getIdentifier());

$parEntity = $this->pushedAuthorizationRequestEntityFactory->buildNew(
$client->getIdentifier(),
$parameters,
);

$this->pushedAuthorizationRequestRepository->persist($parEntity);

$responseBody = json_encode(
[
'request_uri' => $parEntity->getRequestUri(),
'expires_in' => $this->helpers->dateTime()->getSecondsToExpirationTime(
$parEntity->getExpiresAt()->getTimestamp(),
),
],
JSON_THROW_ON_ERROR,
);

$response = $this->psrHttpBridge->getResponseFactory()->createResponse()
->withStatus(201)
->withHeader('Cache-Control', 'no-cache, no-store')
->withHeader('Content-Type', 'application/json');

$response->getBody()->write($responseBody);

return $response;
}

/**
* Resolve the authorization request parameters which are to be persisted for later use at the
* authorization endpoint.
*
* @param mixed[] $bodyParams
* @return mixed[]
* @throws \League\OAuth2\Server\Exception\OAuthServerException
*/
protected function resolveParametersToPersist(
ResultBagInterface $resultBag,
array $bodyParams,
string $clientId,
): array {
// If a body client_id param was provided, it must match the authenticated client.
if (
array_key_exists(ParamsEnum::ClientId->value, $bodyParams) &&
$bodyParams[ParamsEnum::ClientId->value] !== $clientId
) {
throw OidcServerException::invalidRequest(
ParamsEnum::ClientId->value,
'The client_id parameter does not match the authenticated client.',
);
}

$requestObjectResult = $resultBag->get(RequestObjectRule::class);

if ($requestObjectResult !== null) {
// Request Object (JAR) was used. Per RFC 9126, all authorization request parameters must appear
// as claims of the Request Object, so only use its (validated) payload.
/** @psalm-suppress MixedAssignment */
$parameters = $resultBag->getOrFail(RequestObjectRule::class)->getValue();
$parameters = is_array($parameters) ? $parameters : [];

/** @psalm-suppress MixedAssignment */
$clientIdClaim = $parameters[ParamsEnum::ClientId->value] ?? null;
if (!is_null($clientIdClaim) && $clientIdClaim !== $clientId) {
throw OidcServerException::invalidRequest(
ParamsEnum::ClientId->value,
'The client_id claim in request object does not match the authenticated client.',
);
}
} else {
// Plain pushed authorization request. Make sure not to persist client authentication related
// params (they are not part of the authorization request itself).
$parameters = $bodyParams;
unset(
$parameters[ParamsEnum::ClientSecret->value],
$parameters[ParamsEnum::ClientAssertion->value],
$parameters[ParamsEnum::ClientAssertionType->value],
);
}

unset(
$parameters[ParamsEnum::Request->value],
$parameters[ParamsEnum::RequestUri->value],
);

// Bind the parameters to the authenticated client.
$parameters[ParamsEnum::ClientId->value] = $clientId;

return $parameters;
}

public function par(Request $request): Response
{
try {
$psrRequest = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request);
$psrResponse = $this->__invoke($psrRequest);
return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse);
} catch (OAuthServerException $exception) {
// Per RFC 9126, the error response format is the one specified for the token endpoint, so make
// sure we never redirect (regardless of any redirect URI contained in the exception).
return $this->errorResponder->forExceptionJson($exception);
} catch (\Throwable $exception) {
$this->logger->error(
'PushedAuthorizationController: error processing request: ' . $exception->getMessage(),
);
return $this->errorResponder->forExceptionJson(
OidcServerException::serverError('Unable to process pushed authorization request.'),
);
}
}
}
47 changes: 47 additions & 0 deletions src/Entities/ClientEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ public function toArray(): array

// Extra metadata
ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(),
ClaimsEnum::RequirePushedAuthorizationRequests->value => $this->getRequirePushedAuthorizationRequests(),
ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(),
ClaimsEnum::RequestUris->value => $this->getRequestUris(),
];
}

Expand Down Expand Up @@ -406,4 +409,48 @@ public function getAllowedResponseModes(): array
ResponseModesEnum::FormPost->value,
];
}

public function getRequirePushedAuthorizationRequests(): bool
{
if (!is_array($this->extraMetadata)) {
return false;
}

return (bool)($this->extraMetadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false);
}

public function getRequireSignedRequestObject(): bool
{
if (!is_array($this->extraMetadata)) {
return false;
}

return (bool)($this->extraMetadata[ClaimsEnum::RequireSignedRequestObject->value] ?? false);
}

/**
* @return string[]
*/
public function getRequestUris(): array
{
if (!is_array($this->extraMetadata)) {
return [];
}

/** @var mixed $uris */
$uris = $this->extraMetadata[ClaimsEnum::RequestUris->value] ?? null;
if (!is_array($uris)) {
return [];
}

$stringUris = [];
/** @var mixed $uri */
foreach ($uris as $uri) {
if (is_string($uri)) {
$stringUris[] = $uri;
}
}

return $stringUris;
}
}
Loading
Loading