diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index e88813ce..e67bfafe 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1037,10 +1037,27 @@ $config = [ /** * (optional) Enable or disable API capabilities. Default is disabled - * (false). + * (false). If API capabilities are enabled, you can enable or disable + * specific API endpoints as needed and set up API tokens to allow + * access to those endpoints. If API capabilities are disabled, all API + * endpoints will be inaccessible regardless of the settings for + * specific endpoints and API tokens. + * */ ModuleConfig::OPTION_API_ENABLED => false, + /** + * (optional) API Enable VCI Credential Offer API endpoint. Default is + * disabled (false). Only relevant if API capabilities are enabled. + */ + ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => false, + + /** + * (optional) API Enable OAuth2 Token Introspection API endpoint. Default + * is disabled (false). Only relevant if API capabilities are enabled. + */ + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => false, + /** * List of API tokens which can be used to access API endpoints based on * given scopes. The format is: ['token' => [ApiScopesEnum]] @@ -1050,6 +1067,11 @@ $config = [ // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll, // Gives access to all VCI-related endpoints. // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer, // Gives access to the credential offer endpoint. +// ], +// 'strong-random-token-string-2' => [ +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All, // Gives access to all OAuth2-related endpoints. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection, // Gives access to the token introspection endpoint. // ], ], ]; diff --git a/docker/ssp/config-override.php b/docker/ssp/config-override.php index 0aa1e88f..29fdb306 100644 --- a/docker/ssp/config-override.php +++ b/docker/ssp/config-override.php @@ -11,7 +11,6 @@ 'database.dsn' => getenv('DB.DSN') ?: 'sqlite:/var/simplesamlphp/data/mydb.sq3', 'database.username' => getenv('DB.USERNAME') ?: 'user', 'database.password' => getenv('DB.PASSWORD') ?: 'password', - 'language.i18n.backend' => 'gettext/gettext', 'logging.level' => 7, - 'usenewui' => false, + ] + $config; \ No newline at end of file diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index 5aacdb73..e44f3049 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -128,4 +128,16 @@ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // Use defaults ], + + ModuleConfig::OPTION_API_ENABLED => true, + + ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => true, + + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => true, + + ModuleConfig::OPTION_API_TOKENS => [ + 'strong-random-token-string' => [ + \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. + ], + ], ]; diff --git a/docs/1-oidc.md b/docs/1-oidc.md index a5891f74..94eb17e7 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -77,3 +77,4 @@ Upgrading? See the [upgrade guide](6-oidc-upgrade.md). - Conformance tests: [OpenID Conformance](5-oidc-conformance.md) - Upgrading between versions: [Upgrade guide](6-oidc-upgrade.md) - Common questions: [FAQ](7-oidc-faq.md) +- API documentation: [API](8-api.md) diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 1c2ef70f..699c27fa 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -3,6 +3,15 @@ This is an upgrade guide from versions 1 → 7. Review the changes and apply those relevant to your deployment. +In general, when upgrading any of the SimpleSAMLphp modules or the +SimpleSAMLphp instance itself, you should clear the SimpleSAMLphp +cache after the upgrade. In newer versions of SimpleSAMLphp, the +following command is available to do that: + +```shell +composer clear-symfony-cache +``` + ## Version 6 to 7 As the database schema has been updated, you will have to run the DB migrations @@ -15,6 +24,8 @@ keys for protocol (Connect), Federation, and VCI purposes. This was introduced to support signature algorithm negotiation with the clients. - Clients can now be configured with new properties: - ID Token Signing Algorithm (`id_token_signed_response_alg`) +- Optional OAuth2 Token Introspection endpoint, as per RFC7662. Check the API +documentation for more details. - Initial support for OpenID for Verifiable Credential Issuance (OpenID4VCI). Note that the implementation is experimental. You should not use it in production. @@ -34,6 +45,8 @@ key roll-ower scenarios, etc. - `ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY` - optional, used for setting allowed time tolerance for timestamp validation in artifacts like JWSs. multiple Federation-related signing algorithms and key pairs. +- `ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED` - +optional, enables the OAuth2 token introspection endpoint as per RFC7662. - Several new options regarding experimental support for OpenID4VCI. Major impact changes: diff --git a/docs/api.md b/docs/8-api.md similarity index 60% rename from docs/api.md rename to docs/8-api.md index 68b8ee62..7444dc31 100644 --- a/docs/api.md +++ b/docs/8-api.md @@ -30,8 +30,10 @@ ModuleConfig::OPTION_API_TOKENS => [ Scopes determine which endpoints are accessible by the API access token. The following scopes are available: * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All`: Access to all endpoints. -* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll`: Access to all VCI-related endpoints +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll`: Access to all VCI-related endpoints. * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer`: Access to credential offer endpoint. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All`: Access to all OAuth2-related endpoints. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection`: Access to the OAuth2 token introspection endpoint. ## API Endpoints @@ -142,4 +144,117 @@ Response: { "credential_offer_uri": "openid-credential-offer://?credential_offer={\"credential_issuer\":\"https:\\/\\/idp.mivanci.incubator.hexaa.eu\",\"credential_configuration_ids\":[\"ResearchAndScholarshipCredentialDcSdJwt\"],\"grants\":{\"urn:ietf:params:oauth:grant-type:pre-authorized_code\":{\"pre-authorized_code\":\"_ffcdf6d86cd564c300346351dce0b4ccb2fde304e2\",\"tx_code\":{\"input_mode\":\"numeric\",\"length\":4,\"description\":\"Please provide the one-time code that was sent to e-mail testuser@example.com\"}}}}" } -``` \ No newline at end of file +``` + +### Token Introspection + +Enables token introspection for OAuth2 access tokens and refresh tokens as per +[RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662). + +#### Path + +`/api/oauth2/token-introspection` + +#### Method + +`POST` + +#### Authorization + +Access is granted if: +* The client is authenticated using one of the supported OAuth2 client +authentication methods (Basic, Post, Private Key JWT, Bearer). +* Or, if the request is authorized using an API Bearer Token with +the appropriate scope. + +#### Request + +The request is sent with `application/x-www-form-urlencoded` encoding with the +following parameters: + +* __token__ (string, mandatory): The string value of the token. +* __token_type_hint__ (string, optional): A hint about the type of the +token submitted for introspection. Allowed values: + * `access_token` + * `refresh_token` + +#### Response + +The response is a JSON object with the following fields: + +* __active__ (boolean, mandatory): Indicator of whether or not the presented +token is currently active. +* __scope__ (string, optional): A JSON string containing a space-separated +list of scopes associated with this token. +* __client_id__ (string, optional): Client identifier for the OAuth 2.0 client +that requested this token. +* __token_type__ (string, optional): Type of the token as defined in OAuth 2.0. +* __exp__ (integer, optional): Expiration time. +* __iat__ (integer, optional): Issued at time. +* __nbf__ (integer, optional): Not before time. +* __sub__ (string, optional): Subject identifier for the user who +authorized the token. +* __aud__ (string/array, optional): Audience for the token. +* __iss__ (string, optional): Issuer of the token. +* __jti__ (string, optional): Identifier for the token. + +If the token is not active, only the `active` field with a value of +`false` is returned. + +#### Sample 1 + +Introspect an active access token using an API Bearer Token. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/oauth2/token-introspection' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Bearer ***' \ +--data-urlencode 'token=access-token-string' +``` + +Response: + +```json +{ + "active": true, + "scope": "openid profile email", + "client_id": "test-client", + "token_type": "Bearer", + "exp": 1712662800, + "iat": 1712659200, + "sub": "user-id", + "aud": "test-client", + "iss": "https://idp.mivanci.incubator.hexaa.eu", + "jti": "token-id" +} +``` + +#### Sample 2 + +Introspect a refresh token using an API Bearer Token. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/oauth2/token-introspection' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Bearer ***' \ +--data-urlencode 'token=refresh-token-string' \ +--data-urlencode 'token_type_hint=refresh_token' +``` + +Response: + +```json +{ + "active": true, + "scope": "openid profile", + "client_id": "test-client", + "exp": 1715251200, + "sub": "user-id", + "aud": "test-client", + "jti": "refresh-token-id" +} +``` diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index bbce5f8e..00000000 --- a/docs/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# SimpleSAMLphp OIDC module - -* [API](api.md) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 58a82096..47d02af3 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; 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\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -142,4 +143,10 @@ RoutesEnum::ApiVciCredentialOffer->value, )->controller([VciCredentialOfferApiController::class, 'credentialOffer']) ->methods([HttpMethodsEnum::POST->value]); + + $routes->add( + RoutesEnum::ApiOAuth2TokenIntrospection->name, + RoutesEnum::ApiOAuth2TokenIntrospection->value, + )->controller(TokenIntrospectionController::class) + ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index 8548e461..84054d20 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -96,6 +96,7 @@ services: SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~ SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ + SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~ SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor: factory: ['@SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory', 'build'] SimpleSAML\Module\oidc\Utils\FederationCache: diff --git a/src/Bridges/OAuth2Bridge.php b/src/Bridges/OAuth2Bridge.php new file mode 100644 index 00000000..0eebec4d --- /dev/null +++ b/src/Bridges/OAuth2Bridge.php @@ -0,0 +1,66 @@ +moduleConfig->getEncryptionKey(); + + try { + return $encryptionKey instanceof Key ? + Crypto::encrypt($unencryptedData, $encryptionKey) : + Crypto::encryptWithPassword($unencryptedData, $encryptionKey); + } catch (\Exception $e) { + throw new OidcException('Error encrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Bridge `decrypt` function, which can be used instead of + * \League\OAuth2\Server\CryptTrait::decrypt() + * + * @param string $encryptedData + * @param Key|string $encryptionKey + * @return string + * @throws OidcException + */ + public function decrypt( + string $encryptedData, + null|Key|string $encryptionKey = null, + ): string { + $encryptionKey ??= $this->moduleConfig->getEncryptionKey(); + + try { + return $encryptionKey instanceof Key ? + Crypto::decrypt($encryptedData, $encryptionKey) : + Crypto::decryptWithPassword($encryptedData, $encryptionKey); + } catch (\Exception $e) { + throw new OidcException('Error decrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + } + } +} diff --git a/src/Codebooks/ApiScopesEnum.php b/src/Codebooks/ApiScopesEnum.php index 00006ac2..b87ba692 100644 --- a/src/Codebooks/ApiScopesEnum.php +++ b/src/Codebooks/ApiScopesEnum.php @@ -11,4 +11,8 @@ enum ApiScopesEnum: string // Verifiable Credential Issuance related scopes. case VciAll = 'vci_all'; // Gives access to all VCI-related endpoints. case VciCredentialOffer = 'vci_credential_offer'; // Gives access to the credential offer endpoint. + + // OAuth2 related scopes. + case OAuth2All = 'oauth2_all'; // Gives access to all OAuth2-related endpoints. + case OAuth2TokenIntrospection = 'oauth2_token_introspection'; // Gives access to the token introspection endpoint. } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6e44dbf2..a97d0efe 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -75,4 +75,5 @@ enum RoutesEnum: string ****************************************************************************************************************/ case ApiVciCredentialOffer = 'api/vci/credential-offer'; + case ApiOAuth2TokenIntrospection = 'api/oauth2/token-introspection'; } diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index 77d4b52e..58e84f81 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -40,9 +40,15 @@ public function __construct( } /** + * @throws OidcServerException */ public function credentialOffer(Request $request): Response { + if (!$this->moduleConfig->getApiVciCredentialOfferEndpointEnabled()) { + $this->loggerService->warning('Credential Offer API endpoint not enabled.'); + throw OidcServerException::forbidden('Credential Offer API endpoint not enabled.'); + } + $this->loggerService->debug('VciCredentialOfferApiController::credentialOffer'); $this->loggerService->debug( diff --git a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php index 9984d16a..46b385d0 100644 --- a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php +++ b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php @@ -4,8 +4,12 @@ namespace SimpleSAML\Module\oidc\Controllers\OAuth2; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Codebooks\AccessTokenTypesEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use Symfony\Component\HttpFoundation\JsonResponse; class OAuth2ServerConfigurationController @@ -13,14 +17,34 @@ class OAuth2ServerConfigurationController public function __construct( protected readonly OpMetadataService $opMetadataService, protected readonly Routes $routes, + protected readonly ModuleConfig $moduleConfig, ) { } public function __invoke(): JsonResponse { // We'll reuse OIDC configuration. + $configuration = $this->opMetadataService->getMetadata(); + + if ( + $this->moduleConfig->getApiEnabled() && + $this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled() + ) { + $configuration[ClaimsEnum::IntrospectionEndpoint->value] = $this->routes->urlApiOAuth2TokenIntrospection(); + $configuration[ClaimsEnum::IntrospectionEndpointAuthMethodsSupported->value] = [ + ClientAuthenticationMethodsEnum::ClientSecretBasic->value, + ClientAuthenticationMethodsEnum::ClientSecretPost->value, + ClientAuthenticationMethodsEnum::PrivateKeyJwt->value, + AccessTokenTypesEnum::Bearer->value, + ]; + $configuration[ClaimsEnum::IntrospectionEndpointAuthSigningAlgValuesSupported->value] = $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(); + } + return $this->routes->newJsonResponse( - $this->opMetadataService->getMetadata(), + $configuration, ); // TODO mivanci Add ability for claim 'signed_metadata' when moving to simplesamlphp/openid, as per diff --git a/src/Controllers/OAuth2/TokenIntrospectionController.php b/src/Controllers/OAuth2/TokenIntrospectionController.php new file mode 100644 index 00000000..bf2bf1a5 --- /dev/null +++ b/src/Controllers/OAuth2/TokenIntrospectionController.php @@ -0,0 +1,256 @@ +moduleConfig->getApiEnabled()) { + $this->loggerService->warning('API capabilities not enabled.'); + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + + if (!$this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled()) { + $this->loggerService->warning('OAuth2 Token Introspection API endpoint not enabled.'); + throw OidcServerException::forbidden('OAuth2 Token Introspection API endpoint not enabled.'); + } + } + + public function __invoke(Request $request): Response + { + try { + $this->ensureAuthenticatedClient($request); + } catch (AuthorizationException $e) { + $this->loggerService->error( + 'TokenIntrospectionController::invoke: AuthorizationException: ' . $e->getMessage(), + ); + return $this->routes->newJsonErrorResponse( + error: 'unauthorized', + description: $e->getMessage(), + httpCode: Response::HTTP_UNAUTHORIZED, + ); + } + + $allowedMethods = [HttpMethodsEnum::POST]; + + $tokenParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Token->value, + $request, + $allowedMethods, + ); + + if (!$tokenParam) { + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Missing token parameter.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::TokenTypeHint->value, + $request, + $allowedMethods, + ); + + $payload = null; + if (is_null($tokenTypeHintParam)) { + $payload = $this->resolveAccessTokenPayload($tokenParam) ?? + $this->resolveRefreshTokenPayload($tokenParam); + } elseif ($tokenTypeHintParam === 'access_token') { + $payload = $this->resolveAccessTokenPayload($tokenParam); + } elseif ($tokenTypeHintParam === 'refresh_token') { + $payload = $this->resolveRefreshTokenPayload($tokenParam); + } + + $payload = $payload ?? ['active' => false]; + + return $this->routes->newJsonResponse($payload); + } + + protected function resolveAccessTokenPayload(string $tokenParam): ?array + { + try { + $accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam); + } catch (\Throwable $e) { + $this->loggerService->error('Access token validation failed: ' . $e->getMessage()); + return null; + } + + // See \SimpleSAML\Module\oidc\Entities\AccessTokenEntity::convertToJWT + // for claims set on the access token. + + $scopeClaim = null; + /** @psalm-suppress MixedAssignment */ + $accessTokenScopes = $accessToken->getPayloadClaim('scopes'); + if (is_array($accessTokenScopes)) { + $scopeClaim = $this->prepareScopeString($accessTokenScopes); + } + + $clientId = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null; + + return array_filter([ + 'active' => true, + 'scope' => $scopeClaim, + 'client_id' => $clientId, + 'token_type' => 'Bearer', + ClaimsEnum::Exp->value => $accessToken->getExpirationTime(), + ClaimsEnum::Iat->value => $accessToken->getIssuedAt(), + ClaimsEnum::Nbf->value => $accessToken->getNotBefore(), + ClaimsEnum::Sub->value => $accessToken->getSubject(), + ClaimsEnum::Aud->value => $accessToken->getAudience(), + ClaimsEnum::Iss->value => $accessToken->getIssuer(), + ClaimsEnum::Jti->value => $accessToken->getJwtId(), + ]); + } + + /** + * @psalm-suppress MixedAssignment + */ + public function resolveRefreshTokenPayload(string $tokenParam): ?array + { + try { + $decryptedToken = $this->oAuth2Bridge->decrypt($tokenParam); + $tokenData = json_decode($decryptedToken, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->loggerService->error('Refresh token decrypting failed: ' . $e->getMessage()); + return null; + } + + if (!is_array($tokenData)) { + $this->loggerService->error('Refresh token has unexpected type.'); + return null; + } + + // See \League\OAuth2\Server\ResponseTypes\BearerTokenResponse::generateHttpResponse for claims set on + // the refresh token. + + $expireTime = is_int($expireTime = $tokenData['expire_time'] ?? null) ? $expireTime : null; + + if (is_null($expireTime)) { + $this->loggerService->error('Refresh token has no expiration time.'); + return null; + } + + if ($expireTime < time()) { + $this->loggerService->error('Refresh token has expired.'); + return null; + } + + $refreshTokenId = is_string($refreshTokenId = $tokenData['refresh_token_id'] ?? null) ? $refreshTokenId : null; + + if (is_null($refreshTokenId)) { + $this->loggerService->error('Refresh token has no ID.'); + return null; + } + + try { + if ($this->refreshTokenRepository->isRefreshTokenRevoked($refreshTokenId)) { + $this->loggerService->error('Refresh token has been revoked.'); + return null; + } + } catch (OidcServerException $e) { + $this->loggerService->error('Refresh token revocation check failed: ' . $e->getMessage()); + return null; + } + + $scopeClaim = null; + $refreshTokenScopes = $tokenData['scopes'] ?? null; + if (is_array($refreshTokenScopes)) { + $scopeClaim = $this->prepareScopeString($refreshTokenScopes); + } + + $clientId = is_string($clientId = $tokenData['client_id'] ?? null) ? $clientId : null; + + return array_filter([ + 'active' => true, + 'scope' => $scopeClaim, + 'client_id' => $clientId, + ClaimsEnum::Exp->value => $expireTime, + ClaimsEnum::Sub->value => is_string($tokenData['user_id'] ?? null) ? $tokenData['user_id'] : null, + ClaimsEnum::Aud->value => $clientId, + ClaimsEnum::Jti->value => $refreshTokenId, + ]); + } + + protected function prepareScopeString(array $scopes): string + { + $scopes = array_filter( + $scopes, + static fn($scope) => is_string($scope) && !empty($scope), + ); + + return implode(' ', $scopes); + } + + /** + * @throws AuthorizationException + */ + protected function ensureAuthenticatedClient(Request $request): void + { + $this->loggerService->debug('TokenIntrospectionController::ensureAuthenticatedClient - start'); + $this->loggerService->debug('Trying supported OAuth2 client authentication methods.'); + + // First, try regular OAuth2 client authentication methods. + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); + + if ( + $resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod && + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() + ) { + $this->loggerService->debug( + sprintf( + 'Client %s authenticated using supported OAuth2 client authentication method %s.', + $resolvedClientAuthenticationMethod->getClient()->getIdentifier(), + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value, + ), + ); + + return; + } + + $this->loggerService->debug('No regular OAuth2 client authentication method found.'); + $this->loggerService->debug('Trying API client authentication method.'); + + $this->apiAuthorization->requireTokenForAnyOfScope( + $request, + [ApiScopesEnum::OAuth2TokenIntrospection, ApiScopesEnum::OAuth2All, ApiScopesEnum::All], + ); + + $this->loggerService->debug('API client authenticated.'); + } +} diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 8e865d41..9b77e7c0 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -38,6 +38,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Services\AuthenticationService; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; @@ -68,6 +69,7 @@ public function __construct( private readonly SspBridge $sspBridge, private readonly Jwks $jwks, private readonly Core $core, + private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, ) { @@ -145,9 +147,7 @@ private function getDefaultRules(): array new ClientAuthenticationRule( $this->requestParamsResolver, $this->helpers, - $this->moduleConfig, - $this->jwksResolver, - $this->protocolCache, + $this->authenticatedOAuth2ClientResolver, ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 55d22640..a2037f94 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -100,6 +100,9 @@ class ModuleConfig final public const OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = 'vci_user_attribute_to_credential_claim_path_map'; final public const OPTION_API_ENABLED = 'api_enabled'; + final public const OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED = 'api_vci_credential_offer_endpoint_enabled'; + final public const OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED = + 'api_oauth2_token_introspection_endpoint_enabled'; final public const OPTION_API_TOKENS = 'api_tokens'; final public const OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME = 'users_email_attribute_name'; final public const OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP = @@ -1038,6 +1041,16 @@ public function getApiEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_API_ENABLED, false); } + public function getApiVciCredentialOfferEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED, false); + } + + public function getApiOAuth2TokenIntrospectionEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, false); + } + /** * @return mixed[]|null */ diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 5693e220..dbbbce0b 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -70,6 +70,7 @@ use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -511,8 +512,8 @@ public function respondToAccessTokenRequest( $authorizationClientEntity : $resultBag->getOrFail(ClientRule::class)->getValue(); - /** @var ?string $clientAuthenticationParam */ - $clientAuthenticationParam = $authorizationClientEntity->isGeneric() ? + /** @var ?ResolvedClientAuthenticationMethod $resolvedClientAuthenticationMethod */ + $resolvedClientAuthenticationMethod = $authorizationClientEntity->isGeneric() ? null : $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); @@ -521,8 +522,13 @@ public function respondToAccessTokenRequest( $utilizedClientAuthenticationParams = []; - if (!is_null($clientAuthenticationParam)) { - $utilizedClientAuthenticationParams[] = $clientAuthenticationParam; + if ( + $resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod && + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() + ) { + $utilizedClientAuthenticationParams[] = $resolvedClientAuthenticationMethod + ->getClientAuthenticationMethod() + ->value; } if (!is_null($codeVerifier)) { $utilizedClientAuthenticationParams[] = ParamsEnum::CodeVerifier->value; diff --git a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php index 478d4689..7a062bd6 100644 --- a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php +++ b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php @@ -5,31 +5,22 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\JwksResolver; -use SimpleSAML\Module\oidc\Utils\ProtocolCache; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Codebooks\ClientAssertionTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\Codebooks\ParamsEnum; class ClientAuthenticationRule extends AbstractRule { - protected const KEY_CLIENT_ASSERTION_JTI = 'client_assertion_jti'; - public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, - protected ModuleConfig $moduleConfig, - protected JwksResolver $jwksResolver, - protected ?ProtocolCache $protocolCache, + protected AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -46,102 +37,34 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); - - // We will only perform client authentication if the client type is confidential. - if (!$client->isConfidential()) { - return new Result($this->getKey(), null); - } - // Let's check if client secret is provided. - /** @var ?string $clientSecret */ - $clientSecret = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::ClientSecret->value, - $request, - $allowedServerRequestMethods, - ) ?? $request->getServerParams()['PHP_AUTH_PW'] ?? null; + $loggerService->debug('ClientAuthenticationRule::checkRule'); - if ($clientSecret) { - hash_equals($client->getSecret(), $clientSecret) || throw OidcServerException::invalidClient($request); - return new Result($this->getKey(), ParamsEnum::ClientSecret->value); - } + // TODO mivanci Instead of ClientRule which mandates client, this should + // be refactored to use optional client_id parameter and then + // fetch client if present. + /** @var ?\SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $preFetchedClient */ + $preFetchedClient = $currentResultBag->get(ClientRule::class)?->getValue(); - // No client_secret provided, meaning client_secret_post or client_secret_basic client authentication methods - // were not used. Let's check for private_key_jwt method. - $clientAssertionParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::ClientAssertion->value, - $request, - $allowedServerRequestMethods, + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod( + request: $request, + preFetchedClient: $preFetchedClient, ); - if (is_null($clientAssertionParam)) { + if (is_null($resolvedClientAuthenticationMethod)) { throw OidcServerException::accessDenied('Not a single client authentication method presented.'); } - // private_key_jwt authentication method is used. - // Check the expected assertion type param. - $clientAssertionType = $this->requestParamsResolver->getfromRequestBasedOnAllowedMethods( - ParamsEnum::ClientAssertionType->value, - $request, - $allowedServerRequestMethods, - ); - - if ($clientAssertionType !== ClientAssertionTypesEnum::JwtBaerer->value) { - throw OidcServerException::invalidRequest(ParamsEnum::ClientAssertionType->value); - } - - $clientAssertion = $this->requestParamsResolver->parseClientAssertionToken($clientAssertionParam); - - // Check if the Client Assertion token has already been used. Only applicable if we have cache available. - if ($this->protocolCache) { - ($this->protocolCache->has(self::KEY_CLIENT_ASSERTION_JTI, $clientAssertion->getJwtId()) === false) - || throw OidcServerException::invalidRequest( - ParamsEnum::ClientAssertion->value, - 'Client Assertion reused.', - ); - } - - ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( - 'Can not validate Client Assertion, client JWKS not available.', - ); - - try { - $clientAssertion->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { + // Ensure we that the method is not 'None' if client is confidential. + if ( + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNone() && + $resolvedClientAuthenticationMethod->getClient()->isConfidential() + ) { throw OidcServerException::accessDenied( - 'Client Assertion validation failed: ' . $exception->getMessage(), + 'Confidential client must use an authentication method other than "none".', ); } - ($client->getIdentifier() === $clientAssertion->getIssuer()) || throw OidcServerException::accessDenied( - 'Invalid Client Assertion Issuer claim.', - ); - - ($client->getIdentifier() === $clientAssertion->getSubject()) || throw OidcServerException::accessDenied( - 'Invalid Client Assertion Subject claim.', - ); - - // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. - // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization - // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. - $expectedAudience = [ - $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), - $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), - $this->moduleConfig->getIssuer(), - ]; - - (!empty(array_intersect($expectedAudience, $clientAssertion->getAudience()))) || - throw OidcServerException::accessDenied('Invalid Client Assertion Audience claim.'); - - // Everything seems ok. Save it in cache so we can check for reuse. - $this->protocolCache?->set( - $clientAssertion->getJwtId(), - $this->helpers->dateTime()->getSecondsToExpirationTime($clientAssertion->getExpirationTime()), - self::KEY_CLIENT_ASSERTION_JTI, - $clientAssertion->getJwtId(), - ); - - return new Result($this->getKey(), ParamsEnum::ClientAssertion->value); + return new Result($this->getKey(), $resolvedClientAuthenticationMethod); } } diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 35ee4d8f..29de4c02 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -75,31 +75,15 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe } try { - // Attempt to parse the JWT - $token = $this->jws->parsedJwsFactory()->fromToken($jwt); - } catch (JwsException $exception) { + $token = $this->ensureValidAccessToken($jwt); + } catch (\Throwable $exception) { throw OidcServerException::accessDenied($exception->getMessage(), null, $exception); } - try { - // Attempt to validate the JWT - $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( - ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), - )->jsonSerialize(); - $token->verifyWithKeySet($jwks); - } catch (JwsException) { - throw OidcServerException::accessDenied('Access token could not be verified'); - } - if (is_null($jti = $token->getJwtId()) || empty($jti)) { throw OidcServerException::accessDenied('Access token malformed (jti missing or unexpected type)'); } - // Check if token has been revoked - if ($this->accessTokenRepository->isAccessTokenRevoked($jti)) { - throw OidcServerException::accessDenied('Access token has been revoked'); - } - // Return the request with additional attributes return $request ->withAttribute('oauth_access_token_id', $jti) @@ -108,6 +92,44 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe ->withAttribute('oauth_scopes', $token->getPayloadClaim('scopes')); } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function ensureValidAccessToken(string $accessTokenJwt): Jws\ParsedJws + { + // Attempt to parse the JWT + $token = $this->jws->parsedJwsFactory()->fromToken($accessTokenJwt); + + // Attempt to validate the JWT + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + $token->verifyWithKeySet($jwks); + + $token->getExpirationTime(); + + if (is_null($iss = $token->getIssuer()) || empty($iss)) { + throw new JwsException('Access token malformed (iss missing or unexpected type)'); + } + + if ($iss !== $this->moduleConfig->getIssuer()) { + throw new JwsException('Access token malformed (iss does not match)'); + } + + if (is_null($jti = $token->getJwtId()) || empty($jti)) { + throw new JwsException('Access token malformed (jti missing or unexpected type)'); + } + + // Check if the token has been revoked + if ($this->accessTokenRepository->isAccessTokenRevoked($jti)) { + throw new JwsException('Access token has been revoked'); + } + + return $token; + } + protected function getTokenFromAuthorizationBearer(string $authorizationHeader): string { return trim((string) preg_replace('/^\s*Bearer\s/', '', $authorizationHeader)); @@ -121,7 +143,7 @@ protected function getTokenFromAuthorizationBearer(string $authorizationHeader): * @return array|string * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ - protected function convertSingleRecordAudToString(mixed $aud): array|string + public function convertSingleRecordAudToString(mixed $aud): array|string { if (is_string($aud)) { return $aud; diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 66979574..fed8e1f0 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -60,28 +60,26 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope } if (empty($token = $this->findToken($request))) { - throw new AuthorizationException(Translate::noop('Token not provided.')); + throw new AuthorizationException(Translate::noop('Authorization token not provided.')); } if (empty($tokenScopes = $this->moduleConfig->getApiTokenScopes($token))) { - throw new AuthorizationException(Translate::noop('Token does not have defined scopes.')); + throw new AuthorizationException(Translate::noop('Authorization token does not have defined scopes.')); } $hasAny = !empty(array_filter($tokenScopes, fn($tokenScope) => in_array($tokenScope, $requiredScopes, true))); if (!$hasAny) { - throw new AuthorizationException(Translate::noop('Token is not authorized.')); + throw new AuthorizationException(Translate::noop('Authorization token is not authorized for this action.')); } } protected function findToken(Request $request): ?string { - /** @psalm-suppress InternalMethod */ - if ($token = trim((string) $request->get(self::KEY_TOKEN))) { - return $token; - } - - if ($request->headers->has(self::KEY_AUTHORIZATION)) { + if ( + is_string($authorizationHeader = $request->headers->get(self::KEY_AUTHORIZATION)) + && str_starts_with($authorizationHeader, 'Bearer ') + ) { return trim( (string) preg_replace( '/^\s*Bearer\s/', @@ -91,6 +89,12 @@ protected function findToken(Request $request): ?string ); } + // Fallback to token parameter. + /** @psalm-suppress InternalMethod */ + if ($token = trim((string) $request->get(self::KEY_TOKEN))) { + return $token; + } + return null; } } diff --git a/src/Services/Container.php b/src/Services/Container.php index 5accbbcf..6aea40f6 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -103,6 +103,7 @@ use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; use SimpleSAML\Module\oidc\Utils\FederationCache; @@ -227,7 +228,31 @@ public function __construct() $federation = $federationFactory->build(); $this->services[Federation::class] = $federation; - $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation); + $httpFoundationFactory = new HttpFoundationFactory(); + $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; + + $serverRequestFactory = new ServerRequestFactory(); + $this->services[ServerRequestFactoryInterface::class] = $serverRequestFactory; + + $responseFactory = new ResponseFactory(); + $this->services[ResponseFactoryInterface::class] = $responseFactory; + + $streamFactory = new StreamFactory(); + $this->services[StreamFactoryInterface::class] = $streamFactory; + + $uploadedFileFactory = new UploadedFileFactory(); + $this->services[UploadedFileFactoryInterface::class] = $uploadedFileFactory; + + $psrHttpBridge = new PsrHttpBridge( + $httpFoundationFactory, + $serverRequestFactory, + $responseFactory, + $streamFactory, + $uploadedFileFactory, + ); + $this->services[PsrHttpBridge::class] = $psrHttpBridge; + + $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation, $psrHttpBridge); $this->services[RequestParamsResolver::class] = $requestParamsResolver; $clientEntityFactory = new ClientEntityFactory( @@ -383,6 +408,18 @@ public function __construct() ); $this->services[FederationParticipationValidator::class] = $federationParticipationValidator; + $authenticatedOAuth2ClientResolver = new AuthenticatedOAuth2ClientResolver( + clientRepository: $clientRepository, + requestParamsResolver: $requestParamsResolver, + loggerService: $loggerService, + psrHttpBridge: $psrHttpBridge, + jwksResolver: $jwksResolver, + moduleConfig: $moduleConfig, + helpers: $helpers, + protocolCache: $protocolCache, + ); + $this->services[AuthenticatedOAuth2ClientResolver::class] = $authenticatedOAuth2ClientResolver; + $requestRules = [ new StateRule($requestParamsResolver, $helpers), new ClientRule( @@ -423,9 +460,7 @@ public function __construct() new ClientAuthenticationRule( $requestParamsResolver, $helpers, - $moduleConfig, - $jwksResolver, - $protocolCache, + $authenticatedOAuth2ClientResolver, ), new CodeVerifierRule($requestParamsResolver, $helpers), ]; @@ -545,29 +580,6 @@ public function __construct() ); $this->services[ResourceServer::class] = $resourceServer; - $httpFoundationFactory = new HttpFoundationFactory(); - $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; - - $serverRequestFactory = new ServerRequestFactory(); - $this->services[ServerRequestFactoryInterface::class] = $serverRequestFactory; - - $responseFactory = new ResponseFactory(); - $this->services[ResponseFactoryInterface::class] = $responseFactory; - - $streamFactory = new StreamFactory(); - $this->services[StreamFactoryInterface::class] = $streamFactory; - - $uploadedFileFactory = new UploadedFileFactory(); - $this->services[UploadedFileFactoryInterface::class] = $uploadedFileFactory; - - $psrHttpBridge = new PsrHttpBridge( - $httpFoundationFactory, - $serverRequestFactory, - $responseFactory, - $streamFactory, - $uploadedFileFactory, - ); - $this->services[PsrHttpBridge::class] = $psrHttpBridge; $errorResponder = new ErrorResponder($psrHttpBridge); $this->services[ErrorResponder::class] = $errorResponder; diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php new file mode 100644 index 00000000..5c86e39c --- /dev/null +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -0,0 +1,417 @@ +forPrivateKeyJwt($request, $preFetchedClient) ?? + $this->forClientSecretBasic($request, $preFetchedClient) ?? + $this->forClientSecretPost($request, $preFetchedClient) ?? + $this->forPublicClient($request, $preFetchedClient); + } catch (\Throwable $exception) { + $this->loggerService->error( + 'Error while trying to resolve authenticated client: ' . + $exception->getMessage(), + ); + return null; + } + } + + /** + * @throws AuthorizationException + */ + public function forPublicClient( + ServerRequestInterface|Request $request, + ?ClientEntityInterface $preFetchedClient, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve public client for request client ID.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + [HttpMethodsEnum::GET, HttpMethodsEnum::POST], + ); + + if (!is_string($clientId) || $clientId === '') { + $this->loggerService->debug( + 'No client ID available in HTTP request, skipping for public client.', + ); + return null; + } + + $this->loggerService->debug('Client ID from HTTP request: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + if ($client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is confidential, aborting for public client.', + ); + throw new AuthorizationException('Client is confidential.'); + } + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::None, + ); + } + + /** + * @throws AuthorizationException + */ + public function forClientSecretBasic( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from basic auth.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $authorizationHeader = $request->getHeader('Authorization')[0] ?? null; + + if (!is_string($authorizationHeader)) { + $this->loggerService->debug( + 'No authorization header available for basic auth, skipping.', + ); + return null; + } + + if (!str_starts_with($authorizationHeader, 'Basic ')) { + $this->loggerService->debug( + 'Authorization header is not in basic auth format, skipping.', + ); + return null; + } + + $decodedAuthorizationHeader = base64_decode(substr($authorizationHeader, 6), true); + + if ($decodedAuthorizationHeader === false) { + $this->loggerService->debug( + 'Authorization header Basic value is invalid, skipping.', + ); + return null; + } + + if (!str_contains($decodedAuthorizationHeader, ':')) { + $this->loggerService->debug( + 'Authorization header Basic value is invalid, skipping.', + ); + return null; + } + + $parts = explode(':', $decodedAuthorizationHeader, 2); + $clientId = $parts[0]; + $clientSecret = $parts[1] ?? ''; + + if ($clientId === '') { + $this->loggerService->debug( + 'No client ID available in basic auth header, skipping.', + ); + return null; + } + + $this->loggerService->debug('Client ID from basic auth: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + // Only do secret validation for confidential clients. Public clients + // should not have a secret provided. + if (!$client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is not confidential, aborting basic auth validation.', + ); + throw new AuthorizationException('Client is not confidential.'); + } + + if ($clientSecret === '') { + $this->loggerService->error('No client secret available in basic auth header.'); + throw new AuthorizationException('No client secret available in basic auth header.'); + } + + $this->loggerService->debug('Client secret provided for basic auth, validating credentials.'); + + $this->validateClientSecret($client, $clientSecret); + + $this->loggerService->debug('Client credentials from basic auth validated.'); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::ClientSecretBasic, + ); + } + + /** + * For client_secret_post authentication method. + * + * @throws AuthorizationException + */ + public function forClientSecretPost( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from HTTP POST body.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + [HttpMethodsEnum::POST], + ); + $clientSecret = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientSecret->value, + $request, + [HttpMethodsEnum::POST], + ); + + if (!is_string($clientId) || $clientId === '') { + $this->loggerService->debug( + 'No client ID available in HTTP POST body, skipping client_secret_post.', + ); + return null; + } + + if (!is_string($clientSecret) || $clientSecret === '') { + $this->loggerService->debug( + 'No client secret available in HTTP POST body, skipping client_secret_post.', + ); + return null; + } + + $this->loggerService->debug('Client ID from HTTP POST body: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + // Only do secret validation for confidential clients. Public clients + // should not have a secret provided. + if (!$client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is not confidential, aborting client_secret_post.', + ); + throw new AuthorizationException('Client is not confidential.'); + } + + $this->loggerService->debug('Client secret provided for HTTP POST body, validating credentials.'); + + $this->validateClientSecret($client, $clientSecret); + + $this->loggerService->debug('Client credentials from HTTP POST body validated.'); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\ClientAssertionException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function forPrivateKeyJwt( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from private key JWT.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $allowedServerRequestMethods = [HttpMethodsEnum::POST]; + + $clientAssertionParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientAssertion->value, + $request, + $allowedServerRequestMethods, + ); + + if (!is_string($clientAssertionParam)) { + $this->loggerService->debug('No client assertion available, skipping.'); + return null; + } + + $this->loggerService->debug('Client assertion param received: ' . $clientAssertionParam); + + // private_key_jwt authentication method is used. + // Check the expected assertion type param. + $clientAssertionType = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientAssertionType->value, + $request, + $allowedServerRequestMethods, + ); + + if ($clientAssertionType !== ClientAssertionTypesEnum::JwtBaerer->value) { + $this->loggerService->debug( + 'Client assertion type is not expected value, skipping.', + ['expected' => ClientAssertionTypesEnum::JwtBaerer->value, 'actual' => $clientAssertionType], + ); + return null; + } + + $clientAssertion = $this->requestParamsResolver->parseClientAssertionToken($clientAssertionParam); + + $client = $this->resolveClientOrFail($clientAssertion->getIssuer(), $preFetchedClient); + + ($jwks = $this->jwksResolver->forClient($client)) || throw new AuthorizationException( + 'Can not validate Client Assertion, client JWKS not available.', + ); + + try { + $clientAssertion->verifyWithKeySet($jwks); + } catch (\Throwable $exception) { + throw new AuthorizationException( + 'Client Assertion validation failed: ' . $exception->getMessage(), + ); + } + + // Check if the Client Assertion token has already been used. Only + // applicable if we have a cache available. + if ($this->protocolCache) { + ($this->protocolCache->has(self::KEY_CLIENT_ASSERTION_JTI, $clientAssertion->getJwtId()) === false) + || throw new AuthorizationException('Client Assertion reused.'); + } + + ($client->getIdentifier() === $clientAssertion->getIssuer()) || throw new AuthorizationException( + 'Invalid Client Assertion Issuer claim.', + ); + + ($client->getIdentifier() === $clientAssertion->getSubject()) || throw new AuthorizationException( + 'Invalid Client Assertion Subject claim.', + ); + + // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. + // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization + // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. + $expectedAudience = [ + $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), + $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), + $this->moduleConfig->getIssuer(), + ]; + + (!empty(array_intersect($expectedAudience, $clientAssertion->getAudience()))) || + throw new AuthorizationException('Invalid Client Assertion Audience claim.'); + + // Everything seems ok. Save it in a cache so we can check for reuse. + $this->protocolCache?->set( + $clientAssertion->getJwtId(), + $this->helpers->dateTime()->getSecondsToExpirationTime($clientAssertion->getExpirationTime()), + self::KEY_CLIENT_ASSERTION_JTI, + $clientAssertion->getJwtId(), + ); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + ); + } + + public function findActiveClient(string $clientId): ?ClientEntityInterface + { + $client = $this->clientRepository->findById($clientId); + + if (is_null($client)) { + $this->loggerService->debug('No client with ID ' . $clientId . ' found.'); + return null; + } + + if (!$client->isEnabled()) { + $this->loggerService->warning('Client with ID ' . $clientId . ' is disabled.'); + return null; + } + + if ($client->isExpired()) { + $this->loggerService->warning('Client with ID ' . $clientId . ' is expired.'); + return null; + } + + $this->loggerService->debug('Client with ID ' . $clientId . ' is active, returning its instance.'); + return $client; + } + + /** + * @throws AuthorizationException + */ + protected function resolveClientOrFail( + string $clientId, + ?ClientEntityInterface $preFetchedClient, + ): ClientEntityInterface { + $client = $preFetchedClient ?: $this->findActiveClientOrFail($clientId); + + if ($client->getIdentifier() !== $clientId) { + $this->loggerService->error( + 'Client ID does not match, expected: ' . $clientId . ', actual: ' . $client->getIdentifier(), + ); + throw new AuthorizationException('Client ID does not match.'); + } + + return $client; + } + + /** + * @throws AuthorizationException + */ + public function findActiveClientOrFail(string $clientId): ClientEntityInterface + { + return $this->findActiveClient($clientId) ?? throw new AuthorizationException( + 'Client with ID ' . $clientId . ' is not active (either not found, not enabled, or expired).', + ); + } + + /** + * @throws AuthorizationException + */ + public function validateClientSecret(ClientEntityInterface $client, string $clientSecret): void + { + hash_equals($client->getSecret(), $clientSecret) || throw new AuthorizationException( + 'Client secret is not valid.', + ); + } +} diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index c18600c7..09176946 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -5,47 +5,57 @@ namespace SimpleSAML\Module\oidc\Utils; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use Symfony\Component\HttpFoundation\Request; /** - * Resolve authorization params from HTTP request (based or not based on used method), and from Request Object param if - * present. + * Resolve authorization params from an HTTP request (based or not based on + * a used method), and from Request Object param if present. */ class RequestParamsResolver { public function __construct( - protected Helpers $helpers, - protected Core $core, - protected Federation $federation, + protected readonly Helpers $helpers, + protected readonly Core $core, + protected readonly Federation $federation, + protected readonly PsrHttpBridge $psrHttpBridge, ) { } /** * Get all HTTP request params (not from Request Object). * - * @param \Psr\Http\Message\ServerRequestInterface $request - * @return array + * @return mixed[] */ - public function getAllFromRequest(ServerRequestInterface $request): array + public function getAllFromRequest(Request|ServerRequestInterface $request): array { + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + return $this->helpers->http()->getAllRequestParams($request); } /** - * Get all HTTP request params based on allowed methods (not from Request Object). + * Get all HTTP request params based on allowed methods (not from + * Request Object). * - * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods - * @return array + * @return mixed[] */ public function getAllFromRequestBasedOnAllowedMethods( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): array { + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + return $this->helpers->http()->getAllRequestParamsBasedOnAllowedMethods( $request, $allowedMethods, @@ -57,7 +67,7 @@ public function getAllFromRequestBasedOnAllowedMethods( * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function getAll(ServerRequestInterface $request): array + public function getAll(Request|ServerRequestInterface $request): array { $requestParams = $this->getAllFromRequest($request); @@ -69,13 +79,14 @@ public function getAll(ServerRequestInterface $request): array /** - * Get all request params based on allowed methods, including those from Request Object if present. + * Get all request params based on allowed methods, including those from + * Request Object if present. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getAllBasedOnAllowedMethods( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): array { $requestParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); @@ -87,24 +98,25 @@ public function getAllBasedOnAllowedMethods( } /** - * Get param value from HTTP request or Request Object if present. + * Get param value from an HTTP request or Request Object if present. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function get(string $paramKey, ServerRequestInterface $request): mixed + public function get(string $paramKey, Request|ServerRequestInterface $request): mixed { return $this->getAll($request)[$paramKey] ?? null; } /** - * Get param value from HTTP request or Request Object if present, based on allowed methods. + * Get param value from an HTTP request or Request Object if present, + * based on allowed methods. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): mixed { $allParams = $this->getAllBasedOnAllowedMethods($request, $allowedMethods); @@ -112,18 +124,18 @@ public function getBasedOnAllowedMethods( } /** - * Get param value as null or string from HTTP request or Request Object if present, based on allowed methods. - * This is convenience method, since in most cases params will be strings (or absent). + * Get param value as null or string from an HTTP request or Request Object + * if present, based on allowed methods. This is a convenience method, + * since in most cases params will be strings (or absent). * * @param string $paramKey - * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @return string|null * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getAsStringBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): ?string { /** @psalm-suppress MixedAssignment */ @@ -133,13 +145,14 @@ public function getAsStringBasedOnAllowedMethods( } /** - * Get param value from HTTP request (not from Request Object), based on allowed methods. + * Get param value from an HTTP request (not from Request Object), based + * on allowed methods. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods */ public function getFromRequestBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): ?string { $allParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); @@ -148,7 +161,8 @@ public function getFromRequestBasedOnAllowedMethods( } /** - * Check if Request Object is present as request param and parse it to use its claims as params. + * Check if Request Object is present as a request param and parse it to + * use its claims as params. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -176,8 +190,8 @@ public function parseRequestObjectToken(string $token): Core\RequestObject } /** - * Parse the Request Object token according to OpenID Federation specification. - * Note that this won't do signature validation of it. + * Parse the Request Object token according to OpenID Federation + * specification. Note that this won't do signature validation of it. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\RequestObjectException @@ -199,13 +213,11 @@ public function parseClientAssertionToken(string $clientAssertionParam): Core\Cl } /** - * @param ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods - * @return bool * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function isVciAuthorizationCodeRequest( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): bool { return diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index f7763577..ba43de0c 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -244,4 +244,9 @@ public function urlApiVciCredentialOffer(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); } + + public function urlApiOAuth2TokenIntrospection(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiOAuth2TokenIntrospection->value, $parameters); + } } diff --git a/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php new file mode 100644 index 00000000..9e0f3590 --- /dev/null +++ b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php @@ -0,0 +1,27 @@ +client; + } + + public function getClientAuthenticationMethod(): ClientAuthenticationMethodsEnum + { + return $this->clientAuthenticationMethod; + } +} diff --git a/tests/unit/src/Bridges/OAuth2BridgeTest.php b/tests/unit/src/Bridges/OAuth2BridgeTest.php new file mode 100644 index 00000000..5963247e --- /dev/null +++ b/tests/unit/src/Bridges/OAuth2BridgeTest.php @@ -0,0 +1,81 @@ +moduleConfig = $this->createMock(ModuleConfig::class); + $this->bridge = new OAuth2Bridge($this->moduleConfig); + } + + + public function testEncryptDecryptWithPasswordFromConfig(): void + { + $password = 'secret-password'; + $this->moduleConfig->method('getEncryptionKey')->willReturn($password); + + $unencrypted = 'secret-data-2'; + $encrypted = $this->bridge->encrypt($unencrypted); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted); + $this->assertEquals($unencrypted, $decrypted); + } + + public function testEncryptDecryptWithExplicitKey(): void + { + $key = Key::createNewRandomKey(); + + $unencrypted = 'secret-data-3'; + $encrypted = $this->bridge->encrypt($unencrypted, $key); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted, $key); + $this->assertEquals($unencrypted, $decrypted); + } + + public function testEncryptDecryptWithExplicitPassword(): void + { + $password = 'secret-password-explicit'; + + $unencrypted = 'secret-data-4'; + $encrypted = $this->bridge->encrypt($unencrypted, $password); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted, $password); + $this->assertEquals($unencrypted, $decrypted); + } + + + public function testDecryptThrowsOidcExceptionOnInvalidData(): void + { + $this->moduleConfig->method('getEncryptionKey')->willReturn('secret-password'); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('Error decrypting data:'); + + $this->bridge->decrypt('invalid-encrypted-data'); + } +} diff --git a/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php new file mode 100644 index 00000000..b0ea6ef0 --- /dev/null +++ b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php @@ -0,0 +1,129 @@ + 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorization', + 'token_endpoint' => 'http://localhost/token', + ]; + + protected MockObject $opMetadataServiceMock; + protected MockObject $routesMock; + protected MockObject $moduleConfigMock; + + protected function setUp(): void + { + $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); + $this->routesMock = $this->createMock(Routes::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->opMetadataServiceMock->method('getMetadata')->willReturn(self::OIDC_OP_METADATA); + } + + protected function mock( + ?OpMetadataService $opMetadataService = null, + ?Routes $routes = null, + ?ModuleConfig $moduleConfig = null, + ): OAuth2ServerConfigurationController { + return new OAuth2ServerConfigurationController( + $opMetadataService ?? $this->opMetadataServiceMock, + $routes ?? $this->routesMock, + $moduleConfig ?? $this->moduleConfigMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf( + OAuth2ServerConfigurationController::class, + $this->mock(), + ); + } + + public function testItReturnsConfigurationWithoutIntrospectionIfApiDisabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(self::OIDC_OP_METADATA) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } + + public function testItReturnsConfigurationWithoutIntrospectionIfIntrospectionDisabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(false); + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(self::OIDC_OP_METADATA) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } + + public function testItReturnsConfigurationWithIntrospectionEndpointEnabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $signatureAlgorithmBagMock = $this->createMock(SignatureAlgorithmBag::class); + $signatureAlgorithmBagMock->method('getAllNamesUnique')->willReturn(['RS256', 'ES256']); + + $supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); + $supportedAlgorithmsMock->method('getSignatureAlgorithmBag')->willReturn($signatureAlgorithmBagMock); + + $this->moduleConfigMock->method('getSupportedAlgorithms')->willReturn($supportedAlgorithmsMock); + + $introspectionEndpoint = 'http://localhost/introspect'; + $this->routesMock->method('urlApiOAuth2TokenIntrospection')->willReturn($introspectionEndpoint); + + $expectedConfiguration = self::OIDC_OP_METADATA; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpoint->value] = $introspectionEndpoint; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpointAuthMethodsSupported->value] = [ + ClientAuthenticationMethodsEnum::ClientSecretBasic->value, + ClientAuthenticationMethodsEnum::ClientSecretPost->value, + ClientAuthenticationMethodsEnum::PrivateKeyJwt->value, + AccessTokenTypesEnum::Bearer->value, + ]; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpointAuthSigningAlgValuesSupported->value] = [ + 'RS256', + 'ES256', + ]; + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($expectedConfiguration) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } +} diff --git a/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php new file mode 100644 index 00000000..fbb9be28 --- /dev/null +++ b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php @@ -0,0 +1,414 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $this->authenticatedOAuth2ClientResolverMock = $this->createMock(AuthenticatedOAuth2ClientResolver::class); + $this->routesMock = $this->createMock(Routes::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->apiAuthorizationMock = $this->createMock(Authorization::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->bearerTokenValidatorMock = $this->createMock(BearerTokenValidator::class); + $this->oAuth2BridgeMock = $this->createMock(OAuth2Bridge::class); + $this->refreshTokenRepositoryMock = $this->createMock(RefreshTokenRepository::class); + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver = null, + ?Routes $routes = null, + ?LoggerService $loggerService = null, + ?Authorization $apiAuthorization = null, + ?RequestParamsResolver $requestParamsResolver = null, + ?BearerTokenValidator $bearerTokenValidator = null, + ?OAuth2Bridge $oAuth2Bridge = null, + ?RefreshTokenRepository $refreshTokenRepository = null, + ): TokenIntrospectionController { + return new TokenIntrospectionController( + $moduleConfig ?? $this->moduleConfigMock, + $authenticatedOAuth2ClientResolver ?? $this->authenticatedOAuth2ClientResolverMock, + $routes ?? $this->routesMock, + $loggerService ?? $this->loggerServiceMock, + $apiAuthorization ?? $this->apiAuthorizationMock, + $requestParamsResolver ?? $this->requestParamsResolverMock, + $bearerTokenValidator ?? $this->bearerTokenValidatorMock, + $oAuth2Bridge ?? $this->oAuth2BridgeMock, + $refreshTokenRepository ?? $this->refreshTokenRepositoryMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf(TokenIntrospectionController::class, $this->sut()); + } + + public function testConstructThrowsForbiddenIfApiNotEnabled(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); + + $this->expectException(OidcServerException::class); + try { + $this->sut(); + } catch (OidcServerException $e) { + $this->assertSame('API capabilities not enabled.', $e->getHint()); + throw $e; + } + } + + public function testConstructThrowsForbiddenIfIntrospectionNotEnabled(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(false); + + $this->expectException(OidcServerException::class); + try { + $this->sut(); + } catch (OidcServerException $e) { + $this->assertSame('OAuth2 Token Introspection API endpoint not enabled.', $e->getHint()); + throw $e; + } + } + + private function createValidResolvedClientAuthenticationMethodMock(): MockObject&ResolvedClientAuthenticationMethod + { + $mock = $this->createMock(ResolvedClientAuthenticationMethod::class); + $mock->method('getClientAuthenticationMethod')->willReturn(ClientAuthenticationMethodsEnum::ClientSecretBasic); + $clientMock = $this->createMock(ClientEntity::class); + $clientMock->method('getIdentifier')->willReturn('client-id'); + $mock->method('getClient')->willReturn($clientMock); + + return $mock; + } + + public function testInvokeReturnsUnauthorizedOnAuthorizationException(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn(null); + + $this->apiAuthorizationMock->expects($this->once()) + ->method('requireTokenForAnyOfScope') + ->willThrowException(new AuthorizationException('Unauthorized client.')); + + $this->loggerServiceMock->expects($this->once()) + ->method('error') + ->with($this->stringContains('AuthorizationException: Unauthorized client.')); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('unauthorized', 'Unauthorized client.', 401) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsBadRequestIfMissingToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); // client is authenticated + + $this->requestParamsResolverMock->expects($this->once()) + ->method('getFromRequestBasedOnAllowedMethods') + ->with('token', $requestMock, [HttpMethodsEnum::POST]) + ->willReturn(null); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('invalid_request', 'Missing token parameter.', 400) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsActiveFalseIfTokenInvalid(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'invalid-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], null], + ]); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('invalid-token') + ->willThrowException(new \Exception('bad token')); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('invalid-token') + ->willThrowException(new \Exception('bad refresh token')); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(['active' => false]) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeCallsAccessTokenFirstRefreshSecondIfNoHint(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'invalid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], null], + ]); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('invalid-access-token') + ->willThrowException(new \Exception('bad token')); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('invalid-access-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'ref-1', + 'scopes' => ['scope1'], + 'client_id' => 'client1', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('ref-1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client1'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeWithTokenTypeHintAccessToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'access_token'], + ]); + + $jwsMock = $this->createMock(\SimpleSAML\OpenID\Jws\ParsedJws::class); + $jwsMock->method('getPayloadClaim')->with('scopes')->willReturn(['scope2']); + $jwsMock->method('getAudience')->willReturn(['client2']); + $jwsMock->method('getExpirationTime')->willReturn(1000); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('valid-access-token') + ->willReturn($jwsMock); + + $this->oAuth2BridgeMock->expects($this->never())->method('decrypt'); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client2'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeWithTokenTypeHintRefreshToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-refresh-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'refresh_token'], + ]); + + $this->bearerTokenValidatorMock->expects($this->never())->method('ensureValidAccessToken'); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('valid-refresh-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'ref-1', + 'scopes' => ['scope1'], + 'client_id' => 'client3', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('ref-1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client3'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsExpectedAccessTokenPayload(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'access_token'], + ]); + + $jwsMock = $this->createMock(\SimpleSAML\OpenID\Jws\ParsedJws::class); + $jwsMock->method('getPayloadClaim')->with('scopes')->willReturn(['scope1', 'scope2']); + $jwsMock->method('getExpirationTime')->willReturn(1000); + $jwsMock->method('getIssuedAt')->willReturn(500); + $jwsMock->method('getNotBefore')->willReturn(500); + $jwsMock->method('getSubject')->willReturn('sub1'); + $jwsMock->method('getAudience')->willReturn(['client1']); + $jwsMock->method('getIssuer')->willReturn('iss1'); + $jwsMock->method('getJwtId')->willReturn('jti1'); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('valid-access-token') + ->willReturn($jwsMock); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with([ + 'active' => true, + 'scope' => 'scope1 scope2', + 'client_id' => 'client1', + 'token_type' => 'Bearer', + 'exp' => 1000, + 'iat' => 500, + 'nbf' => 500, + 'sub' => 'sub1', + 'aud' => ['client1'], + 'iss' => 'iss1', + 'jti' => 'jti1', + ]) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsExpectedRefreshTokenPayload(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-refresh-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'refresh_token'], + ]); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('valid-refresh-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'jti1', + 'scopes' => ['scope1', 'scope2'], + 'client_id' => 'client1', + 'user_id' => 'sub1', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('jti1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true + && $data['scope'] === 'scope1 scope2' + && $data['client_id'] === 'client1' + && $data['exp'] > time() + && $data['sub'] === 'sub1' + && $data['aud'] === 'client1' + && $data['jti'] === 'jti1'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } +} diff --git a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php index 162cce45..72f9aa55 100644 --- a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php +++ b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php @@ -51,6 +51,7 @@ public function setUp(): void $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class); $this->serverRequest = new ServerRequest(); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn('issuer123'); $this->jwsMock = $this->createMock(Jws::class); $this->jwksMock = $this->createMock(Jwks::class); @@ -62,6 +63,7 @@ public function setUp(): void $this->accessTokenState = [ 'id' => 'accessToken123', + 'iss' => 'issuer123', 'scopes' => '{"openid":"openid","profile":"profile"}', 'expires_at' => date('Y-m-d H:i:s', time() + 60), 'user_id' => 'user123', @@ -80,6 +82,7 @@ public function setUp(): void $this->parsedJwsMock = $this->createMock(ParsedJws::class); $this->parsedJwsMock->method('getJwtId')->willReturn('accessToken123'); $this->parsedJwsMock->method('getAudience')->willReturn([$this->clientId]); + $this->parsedJwsMock->method('getIssuer')->willReturn('issuer123'); } protected function sut( diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php new file mode 100644 index 00000000..b788ec7c --- /dev/null +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -0,0 +1,721 @@ +clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($this->psrHttpFactoryMock); + $this->jwksResolverMock = $this->createMock(JwksResolver::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getModuleUrl') + ->willReturnMap([ + [RoutesEnum::Token->value, self::TOKEN_ENDPOINT], + [RoutesEnum::Authorization->value, 'https://example.org/oidc/authorization.php'], + ]); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); + $this->protocolCacheStub = $this->createStub(ProtocolCache::class); + + $this->serverRequestMock = $this->createMock(ServerRequestInterface::class); + + $this->clientEntityMock = $this->createMock(ClientEntityInterface::class); + $this->clientEntityMock->method('getIdentifier')->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isEnabled')->willReturn(true); + $this->clientEntityMock->method('isExpired')->willReturn(false); + + $this->clientAssertionMock = $this->createMock(ClientAssertion::class); + $this->clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $this->clientAssertionMock->method('getSubject')->willReturn(self::CLIENT_ID); + $this->clientAssertionMock->method('getAudience')->willReturn([self::TOKEN_ENDPOINT]); + $this->clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + $this->clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); + } + + protected function sut(?ProtocolCache $protocolCache = null): AuthenticatedOAuth2ClientResolver + { + return new AuthenticatedOAuth2ClientResolver( + $this->clientRepositoryMock, + $this->requestParamsResolverMock, + $this->loggerServiceMock, + $this->psrHttpBridgeMock, + $this->jwksResolverMock, + $this->moduleConfigMock, + $this->helpersMock, + $protocolCache, + ); + } + + // ----------------------------------------------------------------------- + // Construction + // ----------------------------------------------------------------------- + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(AuthenticatedOAuth2ClientResolver::class, $this->sut()); + } + + // ----------------------------------------------------------------------- + // forPublicClient + // ----------------------------------------------------------------------- + + public function testForPublicClientReturnsNullWhenNoClientIdInRequest(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(null); + + $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); + } + + public function testForPublicClientReturnsNullWhenClientIdIsEmptyString(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(''); + + $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); + } + + public function testForPublicClientThrowsWhenClientIsConfidential(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forPublicClient($this->serverRequestMock, null); + } + + public function testForPublicClientThrowsWhenClientNotFound(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forPublicClient($this->serverRequestMock, null); + } + + public function testForPublicClientReturnsResolvedResultForPublicClient(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forPublicClient($this->serverRequestMock, null); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame(ClientAuthenticationMethodsEnum::None, $result->getClientAuthenticationMethod()); + } + + public function testForPublicClientUsesPreFetchedClientWhenProvided(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + // Repository must NOT be called when a pre-fetched client is provided. + $this->clientRepositoryMock->expects($this->never())->method('findById'); + + $result = $this->sut()->forPublicClient($this->serverRequestMock, $this->clientEntityMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + } + + // ----------------------------------------------------------------------- + // forClientSecretBasic + // ----------------------------------------------------------------------- + + public function testForClientSecretBasicReturnsNullWhenNoAuthorizationHeader(): void + { + $this->serverRequestMock->method('getHeader')->with('Authorization')->willReturn([]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenHeaderIsNotBasic(): void + { + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn(['Bearer some-token']); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenBase64DecodeFailsStrictMode(): void + { + // Characters outside [A-Za-z0-9+/=] are invalid in strict mode. + $invalidBase64 = 'Basic !!!'; + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$invalidBase64]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenDecodedValueHasNoColon(): void + { + // Valid base64 of a string with no colon. + $encoded = 'Basic ' . base64_encode('clientidonly'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenClientIdIsEmpty(): void + { + // Colon present but client ID part is empty: ":secret" + $encoded = 'Basic ' . base64_encode(':some-secret'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicThrowsWhenClientIsNotConfidential(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicThrowsWhenSecretIsEmpty(): void + { + // Colon present but secret part is empty: "clientid:" + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicThrowsWhenSecretIsInvalid(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':wrong-secret'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicReturnsResolvedResultOnSuccess(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forClientSecretBasic($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + $result->getClientAuthenticationMethod(), + ); + } + + public function testForClientSecretBasicConvertsSymfonyRequestToPsr(): void + { + $symfonyRequest = Request::create('/', 'POST'); + + $psrRequest = $this->createMock(ServerRequestInterface::class); + $psrRequest->method('getHeader')->with('Authorization')->willReturn([]); + + $this->psrHttpFactoryMock->expects($this->once()) + ->method('createRequest') + ->with($symfonyRequest) + ->willReturn($psrRequest); + + $result = $this->sut()->forClientSecretBasic($symfonyRequest); + + $this->assertNull($result); + } + + // ----------------------------------------------------------------------- + // forClientSecretPost + // ----------------------------------------------------------------------- + + public function testForClientSecretPostReturnsNullWhenNoClientIdInPostBody(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(null); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostReturnsNullWhenClientIdIsEmpty(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(''); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostThrowsWhenClientIsNotConfidential(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, self::CLIENT_SECRET); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostReturnsNullWhenSecretIsNull(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, null); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostReturnsNullWhenSecretIsEmpty(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, ''); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostThrowsWhenSecretIsInvalid(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, 'wrong-secret'); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostReturnsResolvedResultOnSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, self::CLIENT_SECRET); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forClientSecretPost($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::ClientSecretPost, + $result->getClientAuthenticationMethod(), + ); + } + + // ----------------------------------------------------------------------- + // forPrivateKeyJwt + // ----------------------------------------------------------------------- + + public function testForPrivateKeyJwtReturnsNullWhenNoClientAssertionParam(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(null); + + $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); + } + + public function testForPrivateKeyJwtReturnsNullWhenAssertionTypeIsNotJwtBearer(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', 'unexpected_type'); + + $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); + } + + public function testForPrivateKeyJwtThrowsWhenJwksNotAvailable(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(null); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('client JWKS not available'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenSignatureVerificationFails(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->clientAssertionMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Signature mismatch')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Client Assertion validation failed'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenJtiAlreadyUsed(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('has') + ->with('client_assertion_jti', 'unique-jti-value') + ->willReturn(true); // JTI already in cache → replay attempt + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Client Assertion reused'); + + $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenIssuerClaimDoesNotMatchClientId(): void + { + // The assertion issuer is CLIENT_ID, but we pass a pre-fetched client with a different + // identifier. resolveClientOrFail will detect the mismatch and throw. + $mismatchedClient = $this->createMock(ClientEntityInterface::class); + $mismatchedClient->method('getIdentifier')->willReturn('different-client-id'); + $mismatchedClient->method('isEnabled')->willReturn(true); + $mismatchedClient->method('isExpired')->willReturn(false); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + + // Pass the mismatched client as a pre-fetched client to trigger the ID check. + $this->sut()->forPrivateKeyJwt($this->serverRequestMock, $mismatchedClient); + } + + public function testForPrivateKeyJwtThrowsWhenSubjectClaimDoesNotMatchClientId(): void + { + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getSubject')->willReturn('different-subject'); + $clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Subject claim'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenAudienceClaimDoesNotContainExpectedValue(): void + { + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getSubject')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getAudience')->willReturn(['https://unrelated-aud.example.org']); + $clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + $clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Audience claim'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtReturnsResolvedResultOnSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + $result = $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + $result->getClientAuthenticationMethod(), + ); + } + + public function testForPrivateKeyJwtStoresJtiInCacheAfterSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('has')->willReturn(false); + $protocolCacheMock->expects($this->once()) + ->method('set') + ->with( + 'unique-jti-value', + 60, + 'client_assertion_jti', + 'unique-jti-value', + ); + + $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtSkipsJtiCheckWhenNoCacheProvided(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + // No cache passed — must succeed without any replay check. + $result = $this->sut(null)->forPrivateKeyJwt($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + } + + // ----------------------------------------------------------------------- + // forAnySupportedMethod + // ----------------------------------------------------------------------- + + public function testForAnySupportedMethodReturnsNullWhenNoMethodMatches(): void + { + // All four methods return null (no matching credentials anywhere). + $this->serverRequestMock->method('getHeader')->with('Authorization')->willReturn([]); + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(null); + + $this->assertNull($this->sut()->forAnySupportedMethod($this->serverRequestMock)); + } + + public function testForAnySupportedMethodReturnsNullAndLogsErrorOnException(): void + { + // Trigger a hard exception to verify the catch-all swallows it and logs. + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willThrowException(new \RuntimeException('Unexpected error')); + + $this->loggerServiceMock->expects($this->once())->method('error'); + + $result = $this->sut()->forAnySupportedMethod($this->serverRequestMock); + + $this->assertNull($result); + } + + public function testForAnySupportedMethodPrefersPrivateKeyJwtOverOtherMethods(): void + { + // private_key_jwt assertion present — should resolve first and win. + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnCallback(function (string $paramKey) { + if ($paramKey === ParamsEnum::ClientAssertion->value) { + return 'some-assertion-token'; + } + if ($paramKey === ParamsEnum::ClientAssertionType->value) { + return ClientAssertionTypesEnum::JwtBaerer->value; + } + return null; + }); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + // forClientSecretBasic will be tried after forPrivateKeyJwt succeeds and short-circuits, + // so getHeader should never actually be reached. The PSR bridge is never used here + // because the request is already a ServerRequestInterface. + + $result = $this->sut()->forAnySupportedMethod($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + $result->getClientAuthenticationMethod(), + ); + } + + // ----------------------------------------------------------------------- + // findActiveClient + // ----------------------------------------------------------------------- + + public function testFindActiveClientReturnsNullWhenClientNotFound(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsNullWhenClientIsDisabled(): void + { + $disabledClient = $this->createMock(ClientEntityInterface::class); + $disabledClient->method('getIdentifier')->willReturn(self::CLIENT_ID); + $disabledClient->method('isEnabled')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($disabledClient); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsNullWhenClientIsExpired(): void + { + $expiredClient = $this->createMock(ClientEntityInterface::class); + $expiredClient->method('getIdentifier')->willReturn(self::CLIENT_ID); + $expiredClient->method('isEnabled')->willReturn(true); + $expiredClient->method('isExpired')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($expiredClient); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsClientWhenActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->assertSame($this->clientEntityMock, $this->sut()->findActiveClient(self::CLIENT_ID)); + } + + // ----------------------------------------------------------------------- + // findActiveClientOrFail + // ----------------------------------------------------------------------- + + public function testFindActiveClientOrFailThrowsWhenClientNotActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->findActiveClientOrFail(self::CLIENT_ID); + } + + public function testFindActiveClientOrFailReturnsClientWhenActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->assertSame($this->clientEntityMock, $this->sut()->findActiveClientOrFail(self::CLIENT_ID)); + } + + // ----------------------------------------------------------------------- + // validateClientSecret + // ----------------------------------------------------------------------- + + public function testValidateClientSecretThrowsWhenSecretDoesNotMatch(): void + { + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + + $this->expectException(AuthorizationException::class); + + $this->sut()->validateClientSecret($this->clientEntityMock, 'wrong-secret'); + } + + public function testValidateClientSecretDoesNotThrowWhenSecretMatches(): void + { + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + + // Must not throw. + $this->sut()->validateClientSecret($this->clientEntityMock, self::CLIENT_SECRET); + $this->addToAssertionCount(1); + } +} diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index 0cbc269a..a183bf91 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; @@ -26,6 +27,7 @@ class RequestParamsResolverTest extends TestCase protected MockObject $requestObjectMock; protected MockObject $requestObjectFactoryMock; protected MockObject $federationMock; + protected MockObject $psrHttpBridgeMock; protected array $queryParams = [ 'a' => 'b', @@ -54,18 +56,21 @@ protected function setUp(): void $this->coreMock = $this->createMock(Core::class); $this->coreMock->method('requestObjectFactory')->willReturn($this->requestObjectFactoryMock); $this->federationMock = $this->createMock(Federation::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); } protected function mock( ?MockObject $helpersMock = null, ?MockObject $coreMock = null, ?MockObject $federationMock = null, + ?MockObject $psrHttpBridgeMock = null, ): RequestParamsResolver { $helpersMock ??= $this->helpersMock; $coreMock ??= $this->coreMock; $federationMock ??= $this->federationMock; + $psrHttpBridgeMock ??= $this->psrHttpBridgeMock; - return new RequestParamsResolver($helpersMock, $coreMock, $federationMock); + return new RequestParamsResolver($helpersMock, $coreMock, $federationMock, $psrHttpBridgeMock); } public function testCanCreateInstance(): void diff --git a/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php new file mode 100644 index 00000000..73ea7dfd --- /dev/null +++ b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php @@ -0,0 +1,52 @@ +clientMock = $this->createMock(ClientEntityInterface::class); + } + + protected function sut( + ?ClientEntityInterface $client = null, + ?ClientAuthenticationMethodsEnum $clientAuthenticationMethod = null, + ): ResolvedClientAuthenticationMethod { + $client ??= $this->clientMock; + $clientAuthenticationMethod ??= ClientAuthenticationMethodsEnum::ClientSecretBasic; + + return new ResolvedClientAuthenticationMethod( + $client, + $clientAuthenticationMethod, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $this->sut()); + } + + public function testCanGetProperties(): void + { + $sut = $this->sut( + $this->clientMock, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + + $this->assertSame($this->clientMock, $sut->getClient()); + $this->assertSame(ClientAuthenticationMethodsEnum::ClientSecretPost, $sut->getClientAuthenticationMethod()); + } +}