Skip to content
Merged
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 @@ -14,7 +14,7 @@
"psr/simple-cache": "^3",
"cicnavi/simple-file-cache-php": "^3",
"guzzlehttp/guzzle": "^7.8",
"simplesamlphp/openid": "^0"
"simplesamlphp/openid": "^v0.3.2"
},
"conflict": {
"rector/rector": "2.3.0"
Expand Down
24 changes: 24 additions & 0 deletions docs/2-Pre-Registered-Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum;
use SimpleSAML\OpenID\Codebooks\ResponseModesEnum;
use Cicnavi\Oidc\PreRegisteredClient;
use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum;
use Cicnavi\Oidc\CodeBooks\ParModeEnum;

$oidcClient = new PreRegisteredClient(
// Required parameters
Expand All @@ -53,9 +54,32 @@ $oidcClient = new PreRegisteredClient(
logger: null, // \Psr\Log\LoggerInterface instance
defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::FormPost, // Determines the default authorization request method.
responseMode: null, // Determines the OIDC response mode (e.g., ResponseModesEnum::Query or ResponseModesEnum::FormPost. Fragment is not supported). Null by default.
parMode: ParModeEnum::Auto, // Pushed Authorization Requests (RFC 9126) mode. See below.
);
```

### Pushed Authorization Requests (PAR, RFC 9126)

With PAR, the authorization request parameters are first POSTed directly to the
OP's `pushed_authorization_request_endpoint` (a back-channel, client-authenticated
call). The OP returns a short-lived, one-time `request_uri`, and the browser is
then sent to the authorization endpoint carrying only `client_id` and
`request_uri`. Client authentication uses the same `client_secret_basic`
credentials as the token endpoint, and PKCE / state / nonce are unchanged — only
the *delivery* of the request differs. PAR is orthogonal to
`AuthorizationRequestMethodEnum` (Query / FormPost).

The `parMode` option (`ParModeEnum`) controls when PAR is used:

- `ParModeEnum::Off` — never use PAR. Note: if the OP requires PAR
(`require_pushed_authorization_requests = true`), it will reject the request.
- `ParModeEnum::Auto` (default) — use PAR only when the OP requires it. Otherwise
the authorization request is delivered as usual.
- `ParModeEnum::Required` — always use PAR; an exception is thrown if the OP does
not advertise a `pushed_authorization_request_endpoint`.

The mode can also be overridden per call: `$oidcClient->authorize(parMode: ParModeEnum::Required)`.

## Client usage

To initiate authorization (Authorization Code Flow), that is, to initiate a
Expand Down
24 changes: 23 additions & 1 deletion docs/3-Federated-Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,35 @@ $client = $factory->build();

// Direct instantiation with custom response mode:
use SimpleSAML\OpenID\Codebooks\ResponseModesEnum;
use Cicnavi\Oidc\CodeBooks\ParModeEnum;
$client = new FederatedClient(
entityConfig: $entityConfig,
relyingPartyConfig: $relyingPartyConfig,
responseMode: ResponseModesEnum::FormPost // Optional
responseMode: ResponseModesEnum::FormPost, // Optional
parMode: ParModeEnum::Auto // Optional; Pushed Authorization Requests (RFC 9126) mode. See below.
);
```

### Pushed Authorization Requests (PAR, RFC 9126)

The Federated Client can deliver the authorization request via PAR: the
authorization parameters are POSTed directly to the OP's
`pushed_authorization_request_endpoint`, authenticated with `private_key_jwt`,
and the browser is then sent to the authorization endpoint carrying only
`client_id` and the returned one-time `request_uri`. In this PAR flow plain
authorization parameters are pushed (no signed Request Object is used).

The `parMode` option (`ParModeEnum`) controls when PAR is used:

- `ParModeEnum::Off` — never use PAR (the OP rejects the request if it requires PAR).
- `ParModeEnum::Auto` (default) — use PAR only when the OP advertises
`require_pushed_authorization_requests = true`.
- `ParModeEnum::Required` — always use PAR; throws if the OP advertises no
`pushed_authorization_request_endpoint`.

The mode can also be overridden per call:
`$client->autoRegisterAndAuthenticate($opEntityId, parMode: ParModeEnum::Required)`.

### 2. Initiating Authentication

In your login controller, use the `autoRegisterAndAuthenticate` method. This
Expand Down
39 changes: 39 additions & 0 deletions src/CodeBooks/ParModeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Cicnavi\Oidc\CodeBooks;

/**
* Controls whether the Relying Party (RP) delivers its authorization request
* via Pushed Authorization Requests (PAR, RFC 9126): the RP first POSTs the
* authorization parameters to the OP's PAR endpoint (a back-channel,
* client-authenticated call), receives a one-time 'request_uri', and then
* sends the user agent to the authorization endpoint carrying only 'client_id'
* and 'request_uri'.
*
* This is orthogonal to AuthorizationRequestMethodEnum (Query / FormPost),
* which only controls how the front-channel request is delivered.
*/
enum ParModeEnum
{
/**
* Never use PAR. The authorization request is delivered as usual. Note that
* if the OP requires PAR (require_pushed_authorization_requests = true), it
* will reject the request.
*/
case Off;

/**
* Use PAR only when the OP requires it (advertises
* require_pushed_authorization_requests = true). Otherwise the
* authorization request is delivered as usual. This is the default.
*/
case Auto;

/**
* Always use PAR. If the OP does not advertise a
* 'pushed_authorization_request_endpoint', an exception is thrown.
*/
case Required;
}
166 changes: 129 additions & 37 deletions src/FederatedClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Cicnavi\Oidc\Cache\FileCache;
use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum;
use Cicnavi\Oidc\CodeBooks\ParModeEnum;
use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface;
use Cicnavi\Oidc\DataStore\PhpSessionStore;
use Cicnavi\Oidc\Exceptions\OidcClientException;
Expand Down Expand Up @@ -145,6 +146,7 @@ public function __construct(
protected readonly ?ResponseModesEnum $responseMode = null,
int $maxDiscoveryDepth = 10,
?EntityCollectionStoreInterface $entityCollectionStore = null,
protected readonly ParModeEnum $parMode = ParModeEnum::Auto,
) {
$this->validateResponseMode($this->responseMode);
$this->cache = $cache ?? new FileCache('ofacpc-' . md5($this->entityConfig->getEntityId()));
Expand Down Expand Up @@ -301,6 +303,16 @@ public function getDefaultAuthorizationRequestMethod(): AuthorizationRequestMeth
return $this->defaultAuthorizationRequestMethod;
}

public function getResponseMode(): ?ResponseModesEnum
{
return $this->responseMode;
}

public function getParMode(): ParModeEnum
{
return $this->parMode;
}

public function buildEntityStatement(): EntityStatement
{
$issuedAt = $this->federation->helpers()->dateTime()->getUtc();
Expand Down Expand Up @@ -520,9 +532,11 @@ public function autoRegisterAndAuthenticate(
?string $clientRedirectUri = null,
?AuthorizationRequestMethodEnum $authorizationRequestMethod = null,
?ResponseModesEnum $responseMode = null,
?ParModeEnum $parMode = null,
): ?ResponseInterface {
$authorizationRequestMethod ??= $this->defaultAuthorizationRequestMethod;
$responseMode ??= $this->responseMode;
$parMode ??= $this->parMode;
$this->validateResponseMode($responseMode);
$trustAnchorBag = $this->entityConfig->getTrustAnchorBag();
if ($specificTrustAnchors instanceof TrustAnchorConfigBag) {
Expand Down Expand Up @@ -633,6 +647,69 @@ public function autoRegisterAndAuthenticate(
throw new OidcClientException('OpenID Provider authorization endpoint not available.');
}

$currentTimeUtc = $this->federation->helpers()->dateTime()->getUtc();
$clientRedirectUri = $this->resolveClientRedirectUriForAuthorizationRequest($clientRedirectUri);
$state = $this->requestDataHandler->getState();
$nonce = $this->useNonce ? $this->requestDataHandler->getNonce() : null;
$pkceCodeChallenge = $this->usePkce ?
$this->requestDataHandler->generateCodeChallengeFromCodeVerifier(
$this->requestDataHandler->getCodeVerifier(),
$this->pkceCodeChallengeMethod,
) :
null;
$pkceCodeChallengeMethod = $this->usePkce ? $this->pkceCodeChallengeMethod->value : null;
$scope = $this->relyingPartyConfig->getScopeBag()->toString();

// Set resolved OP metadata for the state, so we can fetch it on callback.
$this->requestDataHandler->setResolvedOpMetadataForState($state, $opResolvedMetadata);
// Set used redirect URI for state, so we can fetch it on callback.
$this->requestDataHandler->setClientRedirectUriForState($state, $clientRedirectUri);

$parEndpoint = $this->requestDataHandler->resolvePushedAuthorizationRequestEndpoint(
$opResolvedMetadata,
$parMode,
);

if (is_string($parEndpoint)) {
$this->logger?->debug('Delivering authorization request via PAR.', ['parEndpoint' => $parEndpoint]);

// PAR pushes plain authorization parameters (no Request Object),
// authenticating the client with 'private_key_jwt'.
$parParameters = array_filter([
ParamsEnum::ResponseType->value => ResponseTypesEnum::Code->value,
ParamsEnum::ClientId->value => $this->entityConfig->getEntityId(),
ParamsEnum::RedirectUri->value => $clientRedirectUri,
ParamsEnum::Scope->value => $scope,
ParamsEnum::ResponseMode->value => $responseMode?->value,
ParamsEnum::State->value => $state,
ParamsEnum::Nonce->value => $nonce,
ParamsEnum::CodeChallenge->value => $pkceCodeChallenge,
ParamsEnum::CodeChallengeMethod->value => $pkceCodeChallengeMethod,
ParamsEnum::LoginHint->value => $loginHint,
]);

$parResponse = $this->requestDataHandler->pushAuthorizationRequest(
clientAuthenticationMethod: ClientAuthenticationMethodsEnum::PrivateKeyJwt,
pushedAuthorizationRequestEndpoint: $parEndpoint,
parameters: $parParameters,
clientId: $this->entityConfig->getEntityId(),
clientAssertion: $this->buildClientAssertion($opResolvedMetadata, $openIdProviderEntityId),
);

// The front-channel request now carries only client_id + request_uri.
$authorizationParameters = [
ParamsEnum::ClientId->value => $this->entityConfig->getEntityId(),
ParamsEnum::RequestUri->value => $parResponse[ParamsEnum::RequestUri->value],
];

return $this->dispatchAuthorizationRequest(
$opAuthorizationEndpoint,
$authorizationParameters,
$authorizationRequestMethod,
$response,
);
}

$signingKeyPair = $this->federation->keyPairResolver()->resolveSignatureKeyPairByAlgorithm(
signatureKeyPairBag: $this->connectSignatureKeyPairBag,
receiverEntityMetadata: $opResolvedMetadata,
Expand Down Expand Up @@ -666,24 +743,6 @@ public function autoRegisterAndAuthenticate(
$rpTrustChain->jsonSerialize(),
);

$currentTimeUtc = $this->federation->helpers()->dateTime()->getUtc();
$clientRedirectUri = $this->resolveClientRedirectUriForAuthorizationRequest($clientRedirectUri);
$state = $this->requestDataHandler->getState();
$nonce = $this->useNonce ? $this->requestDataHandler->getNonce() : null;
$pkceCodeChallenge = $this->usePkce ?
$this->requestDataHandler->generateCodeChallengeFromCodeVerifier(
$this->requestDataHandler->getCodeVerifier(),
$this->pkceCodeChallengeMethod,
) :
null;
$pkceCodeChallengeMethod = $this->usePkce ? $this->pkceCodeChallengeMethod->value : null;
$scope = $this->relyingPartyConfig->getScopeBag()->toString();

// Set resolved OP metadata for the state, so we can fetch it on callback.
$this->requestDataHandler->setResolvedOpMetadataForState($state, $opResolvedMetadata);
// Set used redirect URI for state, so we can fetch it on callback.
$this->requestDataHandler->setClientRedirectUriForState($state, $clientRedirectUri);

$requestObjectPayload = array_filter([
ClaimsEnum::Aud->value => $openIdProviderEntityId,
ClaimsEnum::ClientId->value => $this->entityConfig->getEntityId(),
Expand Down Expand Up @@ -743,6 +802,26 @@ public function autoRegisterAndAuthenticate(
$authorizationParameters,
);

return $this->dispatchAuthorizationRequest(
$opAuthorizationEndpoint,
$authorizationParameters,
$authorizationRequestMethod,
$response,
);
}

/**
* Deliver the front-channel authorization request to the OP, either as an
* auto-submitting POST form or as a redirect, depending on the method.
*
* @param array<string,string> $authorizationParameters
*/
protected function dispatchAuthorizationRequest(
string $opAuthorizationEndpoint,
array $authorizationParameters,
AuthorizationRequestMethodEnum $authorizationRequestMethod,
?ResponseInterface $response,
): ?ResponseInterface {
if ($authorizationRequestMethod === AuthorizationRequestMethodEnum::FormPost) {
$formHtml = HttpHelper::generateAutoSubmitPostForm($opAuthorizationEndpoint, $authorizationParameters);
if ($response instanceof ResponseInterface) {
Expand Down Expand Up @@ -829,9 +908,38 @@ public function getUserData(?ServerRequestInterface $request = null): array
throw new OidcClientException('OpenID Provider entity ID not available.');
}

return $this->requestDataHandler->getUserData(
clientAuthenticationMethod: ClientAuthenticationMethodsEnum::PrivateKeyJwt,
authorizationCode: $authorizationCode,
clientId: $this->entityConfig->getEntityId(),
clientRedirectUri: $clientRedirectUri,
opJwksUri: $opJwksUri,
opTokenEndpoint: $opTokenEndpoint,
opUserinfoEndpoint: $opUserinfoEndpoint,
clientSecret: null,
clientAssertion: $this->buildClientAssertion($resolvedOpMetadata, $opEntityId),
usePkce: $this->usePkce,
useNonce: $this->useNonce,
fetchUserinfoClaims: $this->fetchUserinfoClaims,
expectedIssuer: $opEntityId,
);
}

/**
* Build a signed 'private_key_jwt' client assertion for back-channel client
* authentication (token endpoint, PAR endpoint...). The audience is the
* OP's issuer identifier, which the OP also accepts at its token / PAR
* endpoints per RFC 9126 §2.
*
* @param array<mixed> $opResolvedMetadata Resolved OP metadata.
* @param string $opEntityId OP issuer identifier, used as the assertion audience.
* @return string The serialized client assertion JWT.
*/
protected function buildClientAssertion(array $opResolvedMetadata, string $opEntityId): string
{
$signingKeyPair = $this->federation->keyPairResolver()->resolveSignatureKeyPairByAlgorithm(
signatureKeyPairBag: $this->connectSignatureKeyPairBag,
receiverEntityMetadata: $resolvedOpMetadata,
receiverEntityMetadata: $opResolvedMetadata,
receiverSupportedSignatureAlgorithmsMetadataKey:
ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value,
);
Expand All @@ -851,28 +959,12 @@ public function getUserData(?ServerRequestInterface $request = null): array
ClaimsEnum::Kid->value => $signingKeyPair->getKeyPair()->getKeyId(),
];

$clientAssertion = $this->core->clientAssertionFactory()->fromData(
return $this->core->clientAssertionFactory()->fromData(
$signingKeyPair->getKeyPair()->getPrivateKey(),
$signingKeyPair->getSignatureAlgorithm(),
$clientAssertionPayload,
$clientAssertionHeader,
);

return $this->requestDataHandler->getUserData(
clientAuthenticationMethod: ClientAuthenticationMethodsEnum::PrivateKeyJwt,
authorizationCode: $authorizationCode,
clientId: $this->entityConfig->getEntityId(),
clientRedirectUri: $clientRedirectUri,
opJwksUri: $opJwksUri,
opTokenEndpoint: $opTokenEndpoint,
opUserinfoEndpoint: $opUserinfoEndpoint,
clientSecret: null,
clientAssertion: $clientAssertion->getToken(),
usePkce: $this->usePkce,
useNonce: $this->useNonce,
fetchUserinfoClaims: $this->fetchUserinfoClaims,
expectedIssuer: $opEntityId,
);
)->getToken();
}

/**
Expand Down
Loading
Loading