diff --git a/sample/migrations/m210103_000000_oauth2_sample.php b/sample/migrations/m210103_000000_oauth2_sample.php index a1c6e9b..56675bd 100644 --- a/sample/migrations/m210103_000000_oauth2_sample.php +++ b/sample/migrations/m210103_000000_oauth2_sample.php @@ -1,6 +1,7 @@ '2021-01-01::3vUCADtKx59NPQl3/1fJXmppRbiug3iccJc1S9XY6TPvLE02/+ggB8GtIc24J5oMTj38NIPIpNt8ClNDS7ZBI4+ykNxYOuEHQfdkDiUf5WVKtLegx43gLXfq', # "secret" 'name' => 'Sample client for OpenID Connect with Grant Type Auth Code', 'redirect_uris' => '["http://localhost/redirect_uri/", "https://oauth.pstmn.io/v1/callback"]', + 'post_logout_redirect_uris' => '["http://localhost/post_logout_redirect_uri"]', 'token_types' => 1, # Bearer 'grant_types' => 5, # AUTH_CODE | REFRESH_TOKEN + 'oidc_rp_initiated_logout' => Oauth2ClientInterface::OIDC_RP_INITIATED_LOGOUT_ENABLED, 'enabled' => 1, 'created_at' => time(), 'updated_at' => time(), diff --git a/src/Oauth2Module.php b/src/Oauth2Module.php index 16c97ff..9ace95a 100644 --- a/src/Oauth2Module.php +++ b/src/Oauth2Module.php @@ -24,7 +24,9 @@ use rhertogh\Yii2Oauth2Server\exceptions\Oauth2ServerException; use rhertogh\Yii2Oauth2Server\helpers\DiHelper; use rhertogh\Yii2Oauth2Server\helpers\Psr7Helper; +use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\base\Oauth2BaseAuthorizationRequestInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\client\Oauth2ClientAuthorizationRequestInterface; +use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\EndSession\Oauth2EndSessionAuthorizationRequestInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\common\DefaultAccessTokenTtlInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\encryption\Oauth2CryptographerInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\factories\encryption\Oauth2EncryptionKeyFactoryInterface; @@ -151,7 +153,13 @@ class Oauth2Module extends Oauth2BaseModule implements BootstrapInterface, Defau * Prefix used in session storage of Client Authorization Requests * @since 1.0.0 */ - protected const CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX = 'OATH2_CLIENT_AUTHORIZATION_REQUEST_'; + protected const CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX = 'OAUTH2_CLIENT_AUTHORIZATION_REQUEST_'; + + /** + * Prefix used in session storage of End Session Authorization Requests + * @since 1.0.0 + */ + protected const END_SESSION_AUTHORIZATION_REQUEST_SESSION_PREFIX = 'OAUTH2_END_SESSION_AUTHORIZATION_REQUEST_SESSION_PREFIX_'; /** * Controller mapping for the module. Will be parsed on `init()`. @@ -316,26 +324,29 @@ class Oauth2Module extends Oauth2BaseModule implements BootstrapInterface, Defau public $jwksPath = 'certs'; /** - * The URL to the page where the user can perform the client/scope authorization + * The URL to the page where the user can perform the Client/Scope authorization * (if `null` the build in page will be used). * @return string * @since 1.0.0 + * @see $clientAuthorizationPath */ public $clientAuthorizationUrl = null; /** - * @var string The URL path to the build in page where the user can authorize the client for the requested scopes + * @var string The URL path to the build in page where the user can authorize the Client for the requested Scopes * (will be prefixed with $urlRulesPrefix). * Note: This setting will only be used if $clientAuthorizationUrl is `null`. * @since 1.0.0 + * @see $clientAuthorizationView */ public $clientAuthorizationPath = 'authorize-client'; /** - * @var string The view to use in the "client authorization action" for the page where the user can - * authorize the client for the requested scopes. + * @var string The view to use in the "Client Authorization" action for the page where the user can + * authorize the Client for the requested Scopes. * Note: This setting will only be used if $clientAuthorizationUrl is `null`. * @since 1.0.0 + * @see $clientAuthorizationPath */ public $clientAuthorizationView = 'authorize-client'; @@ -401,6 +412,34 @@ class Oauth2Module extends Oauth2BaseModule implements BootstrapInterface, Defau */ public $openIdConnectRpInitiatedLogoutPath = 'oidc/end-session'; + /** + * The URL to the page where the user can perform the End Session (logout) confirmation + * (if `null` the build in page will be used). + * @return string + * @since 1.0.0 + * @see $openIdConnectLogoutConfirmationPath + */ + public $openIdConnectLogoutConfirmationUrl = null; + + /** + * @var string The URL path to the build in page where the user can confirm the End Session (logout) request + * (will be prefixed with $urlRulesPrefix). + * Note: This setting will only be used if $openIdConnectLogoutConfirmationUrl is `null`. + * @since 1.0.0 + * @see $openIdConnectLogoutConfirmationView + */ + public $openIdConnectLogoutConfirmationPath = 'confirm-logout'; + + /** + * @var string The view to use in the "End Session Authorization" action for the page where the user can + * authorize the End Session (logout) request. + * Note: This setting will only be used if $openIdConnectLogoutConfirmationUrl is `null`. + * @since 1.0.0 + * @see $openIdConnectLogoutConfirmationPath + */ + public $openIdConnectLogoutConfirmationView = 'confirm-logout'; + + /** * @var Oauth2GrantTypeFactoryInterface[]|GrantTypeInterface[]|string[]|Oauth2GrantTypeFactoryInterface|GrantTypeInterface|string|callable * The Oauth 2.0 Grant Types that the module will serve. @@ -638,6 +677,11 @@ public function bootstrap($app) $rules[$this->openIdConnectRpInitiatedLogoutPath] = Oauth2OidcControllerInterface::CONTROLLER_NAME . '/' . Oauth2OidcControllerInterface::ACTION_END_SESSION; + + if (empty($this->openIdConnectLogoutConfirmationUrl)) { + $rules[$this->openIdConnectLogoutConfirmationPath] = Oauth2ConsentControllerInterface::CONTROLLER_NAME + . '/' . Oauth2ConsentControllerInterface::ACTION_NAME_AUTHORIZE_END_SESSION; + } } $urlManager = $app->getUrlManager(); @@ -1059,8 +1103,8 @@ public function validateAuthRequestScopes($client, $requestedScopeIdentifiers, $ } /** - * Generates a redirect Response to the client authorization page where the user is prompted to authorize the - * client and requested scope. + * Generates a redirect Response to the Client Authorization page where the user is prompted to authorize the + * Client and requested Scope. * @param Oauth2ClientAuthorizationRequestInterface $clientAuthorizationRequest * @return Response * @since 1.0.0 @@ -1081,6 +1125,29 @@ public function generateClientAuthReqRedirectResponse($clientAuthorizationReques ]); } + /** + * Generates a redirect Response to the End Session Authorization page where the user is prompted to authorize the + * logout. + * @param Oauth2EndSessionAuthorizationRequestInterface $endSessionAuthorizationRequest + * @return Response + * @since 1.0.0 + */ + public function generateEndSessionAuthReqRedirectResponse($endSessionAuthorizationRequest) + { + $this->setEndSessionAuthReqSession($endSessionAuthorizationRequest); + if (!empty($this->openIdConnectLogoutConfirmationUrl)) { + $url = $this->openIdConnectLogoutConfirmationUrl; + } else { + $url = $this->uniqueId + . '/' . Oauth2ConsentControllerInterface::CONTROLLER_NAME + . '/' . Oauth2ConsentControllerInterface::ACTION_NAME_AUTHORIZE_END_SESSION; + } + return Yii::$app->response->redirect([ + $url, + 'endSessionAuthorizationRequestId' => $endSessionAuthorizationRequest->getRequestId(), + ]); + } + /** * Get a previously stored Client Authorization Request from the session. * @param string $requestId @@ -1088,31 +1155,64 @@ public function generateClientAuthReqRedirectResponse($clientAuthorizationReques * @since 1.0.0 */ public function getClientAuthReqSession($requestId) + { + return $this->getAuthReqSession( + $requestId, + static::CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX, + Oauth2ClientAuthorizationRequestInterface::class, + ); + } + + /** + * Get a previously stored OIDC End Session Authorization Request from the session. + * @param string $requestId + * @return Oauth2EndSessionAuthorizationRequestInterface|null + * @since 1.0.0 + */ + public function getEndSessionAuthReqSession($requestId) + { + return $this->getAuthReqSession( + $requestId, + static::END_SESSION_AUTHORIZATION_REQUEST_SESSION_PREFIX, + Oauth2EndSessionAuthorizationRequestInterface::class, + ); + } + + /** + * Get a previously stored Authorization Request from the session. + * @template T of Oauth2BaseAuthorizationRequestInterface + * @param string $requestId + * @param string $cachePrefix + * @param class-string $expectedInterface + * @return T|null + * @since 1.0.0 + */ + protected function getAuthReqSession($requestId, $cachePrefix, $expectedInterface) { if (empty($requestId)) { return null; } - $key = static::CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX . $requestId; - $clientAuthorizationRequest = Yii::$app->session->get($key); - if (!($clientAuthorizationRequest instanceof Oauth2ClientAuthorizationRequestInterface)) { - if (!empty($clientAuthorizationRequest)) { + $key = $cachePrefix . $requestId; + $authorizationRequest = Yii::$app->session->get($key); + if (!($authorizationRequest instanceof $expectedInterface)) { + if (!empty($authorizationRequest)) { Yii::warning( - 'Found a ClientAuthorizationRequestSession with key "' . $key - . '", but it\'s not a ' . Oauth2ClientAuthorizationRequestInterface::class + 'Found a Authorization Request Session with key "' . $key + . '", but it\'s not a ' . $expectedInterface ); } return null; } - if ($clientAuthorizationRequest->getRequestId() !== $requestId) { + if ($authorizationRequest->getRequestId() !== $requestId) { Yii::warning( - 'Found a ClientAuthorizationRequestSession with key "' . $key - . '", but its request id does not match "' . $requestId . '".' + 'Found a Authorization Request Session with key "' . $key + . '", but its request id does not match "' . $requestId . '".' ); return null; } - $clientAuthorizationRequest->setModule($this); + $authorizationRequest->setModule($this); - return $clientAuthorizationRequest; + return $authorizationRequest; } /** @@ -1122,12 +1222,68 @@ public function getClientAuthReqSession($requestId) */ public function setClientAuthReqSession($clientAuthorizationRequest) { - $requestId = $clientAuthorizationRequest->getRequestId(); + $this->setAuthReqSession($clientAuthorizationRequest, static::CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX); + } + + /** + * Stores the OIDC End Session Authorization Request in the session. + * @param Oauth2EndSessionAuthorizationRequestInterface $endSessionAuthorizationRequest + * @since 1.0.0 + */ + public function setEndSessionAuthReqSession($endSessionAuthorizationRequest) + { + $this->setAuthReqSession($endSessionAuthorizationRequest, static::END_SESSION_AUTHORIZATION_REQUEST_SESSION_PREFIX); + } + + /** + * Stores the Authorization Request in the session. + * @param Oauth2BaseAuthorizationRequestInterface $authorizationRequest + * @param string $cachePrefix + * @since 1.0.0 + */ + protected function setAuthReqSession($authorizationRequest, $cachePrefix) + { + $requestId = $authorizationRequest->getRequestId(); if (empty($requestId)) { - throw new InvalidArgumentException('$scopeAuthorization must return a request id.'); + throw new InvalidArgumentException('$authorizationRequest must return a request id.'); } - $key = static::CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX . $requestId; - Yii::$app->session->set($key, $clientAuthorizationRequest); + $key = $cachePrefix . $requestId; + Yii::$app->session->set($key, $authorizationRequest); + } + + /** + * Clears a Client Authorization Request from the session storage. + * @param string $requestId + * @since 1.0.0 + */ + public function removeClientAuthReqSession($requestId) + { + $this->removeAuthReqSession($requestId, static::CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX); + } + + /** + * Clears an End Session Authorization Request from the session storage. + * @param string $requestId + * @since 1.0.0 + */ + public function removeEndSessionAuthReqSession($requestId) + { + $this->removeAuthReqSession($requestId, static::END_SESSION_AUTHORIZATION_REQUEST_SESSION_PREFIX); + } + + /** + * Clears an Authorization Request from the session storage. + * @param string $requestId + * @param string $cachePrefix + * @since 1.0.0 + */ + public function removeAuthReqSession($requestId, $cachePrefix) + { + if (empty($requestId)) { + throw new InvalidArgumentException('$requestId can not be empty.'); + } + $key = $cachePrefix . $requestId; + Yii::$app->session->remove($key); } /** @@ -1162,20 +1318,6 @@ public function setClientAuthRequestUserIdentity($clientAuthorizationRequestId, } } - /** - * Clears a Client Authorization Request from the session storage. - * @param string $requestId - * @since 1.0.0 - */ - public function removeClientAuthReqSession($requestId) - { - if (empty($requestId)) { - throw new InvalidArgumentException('$requestId can not be empty.'); - } - $key = static::CLIENT_AUTHORIZATION_REQUEST_SESSION_PREFIX . $requestId; - Yii::$app->session->remove($key); - } - /** * Generates a redirect Response when the Client Authorization Request is completed. * @param Oauth2ClientAuthorizationRequestInterface $clientAuthorizationRequest @@ -1189,6 +1331,19 @@ public function generateClientAuthReqCompledRedirectResponse($clientAuthorizatio return Yii::$app->response->redirect($clientAuthorizationRequest->getAuthorizationRequestUrl()); } + /** + * Generates a redirect Response when the End Session Authorization Request is completed. + * @param Oauth2EndSessionAuthorizationRequestInterface $endSessionAuthorizationRequest + * @return Response + * @since 1.0.0 + */ + public function generateEndSessionAuthReqCompledRedirectResponse($endSessionAuthorizationRequest) + { + $endSessionAuthorizationRequest->processAuthorization(); + $this->setEndSessionAuthReqSession($endSessionAuthorizationRequest); + return Yii::$app->response->redirect($endSessionAuthorizationRequest->getEndSessionRequestUrl()); + } + /** * @return IdentityInterface|Oauth2UserInterface|Oauth2OidcUserInterface|null * @throws InvalidConfigException @@ -1351,11 +1506,32 @@ protected function ensureProperties($properties) } } - public function logoutUser() + /** + * @throws InvalidConfigException + */ + public function logoutUser($revokeTokens = true) { - Yii::$app->user->logout(); + $identity = $this->getUserIdentity(); + + if ($identity) { + if ($revokeTokens) { + $this->revokeTokensByUserId($identity->getId()); + } + + Yii::$app->user->logout(); + } } + public function revokeTokensByUserId($userId) + { + $accessTokens = $this->getAccessTokenRepository()->revokeAccessTokensByUserId($userId); + $accessTokenIds = array_map(fn($accessToken) => $accessToken->getPrimaryKey(), $accessTokens); + $this->getRefreshTokenRepository()->revokeRefreshTokensByAccessTokenIds($accessTokenIds); + } + + /** + * @return int + */ public function getElaboratedHttpClientErrorsLogLevel() { if ($this->httpClientErrorsLogLevel === null) { diff --git a/src/base/Oauth2BaseModule.php b/src/base/Oauth2BaseModule.php index f9a8e95..1f165f6 100644 --- a/src/base/Oauth2BaseModule.php +++ b/src/base/Oauth2BaseModule.php @@ -8,6 +8,7 @@ use League\OAuth2\Server\CryptKey; use rhertogh\Yii2Oauth2Server\components\authorization\client\Oauth2ClientAuthorizationRequest; use rhertogh\Yii2Oauth2Server\components\authorization\client\Oauth2ClientScopeAuthorizationRequest; +use rhertogh\Yii2Oauth2Server\components\authorization\EndSession\Oauth2EndSessionAuthorizationRequest; use rhertogh\Yii2Oauth2Server\components\encryption\Oauth2Cryptographer; use rhertogh\Yii2Oauth2Server\components\factories\encryption\Oauth2EncryptionKeyFactory; use rhertogh\Yii2Oauth2Server\components\factories\grants\Oauth2AuthCodeGrantFactory; @@ -55,6 +56,7 @@ use rhertogh\Yii2Oauth2Server\controllers\console\PersonalAccessToken\Oauth2GeneratePatAction; use rhertogh\Yii2Oauth2Server\controllers\web\certificates\Oauth2JwksAction; use rhertogh\Yii2Oauth2Server\controllers\web\consent\Oauth2AuthorizeClientAction; +use rhertogh\Yii2Oauth2Server\controllers\web\consent\Oauth2AuthorizeEndSessionAction; use rhertogh\Yii2Oauth2Server\controllers\web\Oauth2CertificatesController; use rhertogh\Yii2Oauth2Server\controllers\web\Oauth2ConsentController; use rhertogh\Yii2Oauth2Server\controllers\web\Oauth2OidcController; @@ -68,6 +70,7 @@ use rhertogh\Yii2Oauth2Server\controllers\web\wellknown\Oauth2OpenidConfigurationAction; use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\client\Oauth2ClientAuthorizationRequestInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\client\Oauth2ClientScopeAuthorizationRequestInterface; +use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\EndSession\Oauth2EndSessionAuthorizationRequestInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\encryption\Oauth2CryptographerInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\factories\encryption\Oauth2EncryptionKeyFactoryInterface; use rhertogh\Yii2Oauth2Server\interfaces\components\factories\grants\Oauth2AuthCodeGrantFactoryInterface; @@ -116,6 +119,7 @@ use rhertogh\Yii2Oauth2Server\interfaces\controllers\console\PersonalAccessToken\Oauth2GeneratePatActionInterface; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\certificates\Oauth2JwksActionInterface; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\consent\Oauth2AuthorizeClientActionInterface; +use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\consent\Oauth2AuthorizeEndSessionActionInterface; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\Oauth2CertificatesControllerInterface; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\Oauth2ConsentControllerInterface; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\Oauth2OidcControllerInterface; @@ -386,6 +390,7 @@ abstract class Oauth2BaseModule extends Module Oauth2OidcUserinfoActionInterface::class => Oauth2OidcUserinfoAction::class, Oauth2OidcEndSessionActionInterface::class => Oauth2OidcEndSessionAction::class, Oauth2AuthorizeClientActionInterface::class => Oauth2AuthorizeClientAction::class, + Oauth2AuthorizeEndSessionActionInterface::class => Oauth2AuthorizeEndSessionAction::class, Oauth2JwksActionInterface::class => Oauth2JwksAction::class, # Actions (console) Oauth2GeneratePatActionInterface::class => Oauth2GeneratePatAction::class, @@ -418,10 +423,12 @@ abstract class Oauth2BaseModule extends Module Oauth2OidcScopeInterface::class => Oauth2OidcScope::class, Oauth2OidcClaimInterface::class => Oauth2OidcClaim::class, Oauth2OidcBearerTokenResponseInterface::class => Oauth2OidcBearerTokenResponse::class, - # Components (Misc) - Oauth2CryptographerInterface::class => Oauth2Cryptographer::class, + # Authorization Oauth2ClientAuthorizationRequestInterface::class => Oauth2ClientAuthorizationRequest::class, Oauth2ClientScopeAuthorizationRequestInterface::class => Oauth2ClientScopeAuthorizationRequest::class, + Oauth2EndSessionAuthorizationRequestInterface::class => Oauth2EndSessionAuthorizationRequest::class, + # Components (Misc) + Oauth2CryptographerInterface::class => Oauth2Cryptographer::class, ]; /** diff --git a/src/components/authorization/EndSession/Oauth2EndSessionAuthorizationRequest.php b/src/components/authorization/EndSession/Oauth2EndSessionAuthorizationRequest.php new file mode 100644 index 0000000..9165f18 --- /dev/null +++ b/src/components/authorization/EndSession/Oauth2EndSessionAuthorizationRequest.php @@ -0,0 +1,75 @@ +getModule()->getUserIdentity() !== null; + } + + public function processAuthorization() + { + if ($this->isApproved()) { + $this->getModule()->logoutUser(); + } + + $this->setCompleted(true); + } + + /** + * @inheritDoc + */ + public function getEndSessionRequestUrl() + { + return UrlHelper::addQueryParams( + $this->getEndSessionUrl(), + [ + 'endSessionAuthorizationRequestId' => $this->getRequestId() + ] + ); + } + + public function autoApproveAndProcess() + { + if ($this->getEndUserAuthorizationRequired()) { + throw new InvalidCallException('Auto approve is only allowed if end-user authorization is not required.'); + } + + $this->setAuthorizationStatus(Oauth2BaseAuthorizationRequest::AUTHORIZATION_APPROVED); + $this->processAuthorization(); + } + + public function getRequestCompletedRedirectUrl($ignoreApprovalStatus = false) + { + if (!$this->isApproved()) { + return $this->getDeniedRedirectUrl(); + } + + $redirectUri = $this->getRedirectUri(); + + if (!$redirectUri) { + return $this->getDefaultRedirectUrl(); + } + + // Return the original `post_logout_redirect_uri` with the `state` + return UrlHelper::addQueryParams($redirectUri, ['state' => $this->getState()]); + } + + protected function getDefaultRedirectUrl() + { + return Yii::$app->getHomeUrl(); + } + + protected function getDeniedRedirectUrl() + { + return Yii::$app->getHomeUrl(); + } +} diff --git a/src/components/authorization/EndSession/base/Oauth2BaseEndSessionAuthorizationRequest.php b/src/components/authorization/EndSession/base/Oauth2BaseEndSessionAuthorizationRequest.php new file mode 100644 index 0000000..b095d4f --- /dev/null +++ b/src/components/authorization/EndSession/base/Oauth2BaseEndSessionAuthorizationRequest.php @@ -0,0 +1,206 @@ + $this->_idTokenHint, + '_endSessionUrl' => $this->_endSessionUrl, + ]); + } + + /** + * @inheritDoc + */ + public function getIdTokenHint() + { + return $this->_idTokenHint; + } + + /** + * @inheritDoc + */ + public function setIdTokenHint($idTokenHint) + { + $this->_idTokenHint = $idTokenHint; + $this->_validatedRequest = false; + return $this; + } + + public function setClientIdentifier($clientIdentifier) + { + $this->_validatedRequest = false; + return parent::setClientIdentifier($clientIdentifier); + } + + /** + * @inheritDoc + */ + public function getEndSessionUrl() + { + return $this->_endSessionUrl; + } + + /** + * @inheritDoc + */ + public function setEndSessionUrl($endSessionUrl) + { + $this->_endSessionUrl = $endSessionUrl; + return $this; + } + + public function validateRequest() + { + $module = $this->getModule(); + $idTokenHint = $this->getIdTokenHint(); + $clientIdentifier = $this->getClientIdentifier(); + $identity = $module->getUserIdentity(); + $postLogoutRedirectUri = $this->getRedirectUri(); + + $endUserConfirmationMayBeSkipped = false; + + if ($idTokenHint) { + + $parser = new Parser(new JoseEncoder()); + + $idToken = $parser->parse($idTokenHint); + + $validator = new Validator(); + + if (!$validator->validate($idToken, new SignedWith( + new Sha256(), + InMemory::plainText($module->getPublicKey()->getKeyContents()) + ))) { + throw new UnauthorizedHttpException('Invalid `id_token_hint` signature.'); + } + + if ($clientIdentifier) { + if (!$validator->validate($idToken, new PermittedFor($clientIdentifier))) { + throw new UnauthorizedHttpException('Invalid "aud" claim in `id_token_hint`.'); + } + } else { + $audiences = $idToken->claims()->get('aud'); + if (count($audiences) === 1) { + $clientIdentifier = $audiences[0]; + $this->setClientIdentifier($clientIdentifier); + } else { + throw new BadRequestHttpException( + 'The `client_id` parameter is required when there are multiple audiences' + . ' in the "aud" claim of the `id_token_hint`.' + ); + } + } + + if ($identity) { + if ($validator->validate($idToken, new RelatedTo((string)$identity->getIdentifier()))) { + $endUserConfirmationMayBeSkipped = true; + } + } + } else { + if (!$module->openIdConnectAllowAnonymousRpInitiatedLogout) { + throw new BadRequestHttpException('The `id_token_hint` parameter is required.'); + } + } + + if ($clientIdentifier) { + $client = $module->getClientRepository()->getClientEntity($clientIdentifier); + if (!$client || !$client->isEnabled()) { + throw new ForbiddenHttpException('Client "' . $clientIdentifier . '" not found or disabled.'); + } + + if ( + !($client->getOpenIdConnectRpInitiatedLogout() + > Oauth2ClientInterface::OIDC_RP_INITIATED_LOGOUT_DISABLED) + ) { + throw new ForbiddenHttpException('Client "' . $clientIdentifier . '" is not allowed to initiated end-user logout.'); + } + } + + if ($postLogoutRedirectUri) { + if (!empty($client)) { + $allowedPostLogoutRedirectUris = $client->getPostLogoutRedirectUris(); + $validatePostLogoutRedirectUri = $postLogoutRedirectUri; + if ($client->isVariableRedirectUriQueryAllowed()) { + $validatePostLogoutRedirectUri = UrlHelper::stripQueryAndFragment($validatePostLogoutRedirectUri); + } + + $validator = new RedirectUriValidator($allowedPostLogoutRedirectUris); + if (!$validator->validateRedirectUri($validatePostLogoutRedirectUri)) { + throw new UnauthorizedHttpException('Invalid `post_logout_redirect_uri`.'); + } + } else { + throw new UnauthorizedHttpException('`post_logout_redirect_uri` is only allowed if the client is known.'); + } + } + + $endUserAuthorizationRequired = !( + $endUserConfirmationMayBeSkipped + && isset($client) + && ( + $client->getOpenIdConnectRpInitiatedLogout() + === Oauth2ClientInterface::OIDC_RP_INITIATED_LOGOUT_ENABLED_WITHOUT_CONFIRMATION + ) + ); + + $this->setEndUserAuthorizationRequired($endUserAuthorizationRequired); + $this->_validatedRequest = true; + } + + public function getEndUserAuthorizationRequired() + { + if (!$this->_validatedRequest) { + throw new InvalidCallException('Request must be validated first'); + } + + return $this->_endUserAuthorizationRequired; + } + + public function setEndUserAuthorizationRequired($endUserAuthorizationRequired) + { + $this->_endUserAuthorizationRequired = $endUserAuthorizationRequired; + return $this; + } + +} diff --git a/src/components/authorization/base/Oauth2BaseAuthorizationRequest.php b/src/components/authorization/base/Oauth2BaseAuthorizationRequest.php index b29ff4a..cf4c4af 100644 --- a/src/components/authorization/base/Oauth2BaseAuthorizationRequest.php +++ b/src/components/authorization/base/Oauth2BaseAuthorizationRequest.php @@ -6,6 +6,7 @@ use rhertogh\Yii2Oauth2Server\interfaces\models\external\user\Oauth2UserInterface; use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2ClientInterface; use rhertogh\Yii2Oauth2Server\Oauth2Module; +use Yii; use yii\base\InvalidArgumentException; use yii\base\InvalidCallException; use yii\base\Model; @@ -48,6 +49,11 @@ abstract class Oauth2BaseAuthorizationRequest extends Model implements Oauth2Bas */ public $_redirectUri = null; + /** + * @var string|null + */ + protected $_state = null; + /** * @var string|null */ @@ -76,6 +82,7 @@ public function __serialize() '_clientIdentifier' => $this->_clientIdentifier, '_userIdentifier' => $this->_userIdentifier, '_redirectUri' => $this->_redirectUri, + '_state' => $this->_state, '_authorizationStatus' => $this->_authorizationStatus, '_isCompleted' => $this->_isCompleted, ]; @@ -97,7 +104,10 @@ public function __unserialize($data) public function init() { parent::init(); - $this->_requestId = \Yii::$app->security->generateRandomString(128); + + if (empty($this->getRequestId())) { + $this->_requestId = $this->generateRequestId(); + } } /** @@ -252,6 +262,23 @@ public function setRedirectUri($redirectUri) return $this; } + /** + * @inheritDoc + */ + public function getState() + { + return $this->_state; + } + + /** + * @inheritDoc + */ + public function setState($state) + { + $this->_state = $state; + return $this; + } + /** * @inheritDoc */ @@ -282,6 +309,11 @@ public function isApproved() return $this->getAuthorizationStatus() === static::AUTHORIZATION_APPROVED; } + protected function generateRequestId() + { + return Yii::$app->security->generateRandomString(128); + } + /** * @param bool $isCompleted * @return $this diff --git a/src/components/authorization/client/base/Oauth2BaseClientAuthorizationRequest.php b/src/components/authorization/client/base/Oauth2BaseClientAuthorizationRequest.php index e4348b6..19bfc5c 100644 --- a/src/components/authorization/client/base/Oauth2BaseClientAuthorizationRequest.php +++ b/src/components/authorization/client/base/Oauth2BaseClientAuthorizationRequest.php @@ -15,11 +15,6 @@ abstract class Oauth2BaseClientAuthorizationRequest extends Oauth2BaseAuthorizat */ public $_authorizeUrl = null; - /** - * @var string|null - */ - protected $_state = null; - /** * @var string|null */ @@ -59,7 +54,6 @@ public function __serialize() { return array_merge(parent::__serialize(), [ '_authorizeUrl' => $this->_authorizeUrl, - '_state' => $this->_state, '_grantType' => $this->_grantType, '_prompts' => $this->_prompts, '_maxAge' => $this->_maxAge, @@ -118,23 +112,6 @@ public function setAuthorizeUrl($authorizeUrl) return $this; } - /** - * @inheritDoc - */ - public function getState() - { - return $this->_state; - } - - /** - * @inheritDoc - */ - public function setState($state) - { - $this->_state = $state; - return $this; - } - /** * @inheritDoc */ diff --git a/src/components/repositories/Oauth2AccessTokenRepository.php b/src/components/repositories/Oauth2AccessTokenRepository.php index 05f7e8e..6ff8a15 100644 --- a/src/components/repositories/Oauth2AccessTokenRepository.php +++ b/src/components/repositories/Oauth2AccessTokenRepository.php @@ -6,10 +6,14 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use rhertogh\Yii2Oauth2Server\components\repositories\base\Oauth2BaseTokenRepository; use rhertogh\Yii2Oauth2Server\components\repositories\traits\Oauth2ModelRepositoryTrait; +use rhertogh\Yii2Oauth2Server\helpers\DiHelper; use rhertogh\Yii2Oauth2Server\interfaces\components\repositories\Oauth2AccessTokenRepositoryInterface; +use rhertogh\Yii2Oauth2Server\interfaces\models\base\Oauth2IdentifierInterface; +use rhertogh\Yii2Oauth2Server\interfaces\models\base\Oauth2UserIdentifierInterface; use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2AccessTokenInterface; use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2ClientInterface; use yii\base\InvalidConfigException; +use yii\db\Connection; class Oauth2AccessTokenRepository extends Oauth2BaseTokenRepository implements Oauth2AccessTokenRepositoryInterface { @@ -98,4 +102,33 @@ public function setRevocationValidation($validation) $this->_revocationValidation = $validation; return $this; } + + /** + * @inheritDoc + */ + public function revokeAccessTokensByUserId($userId) + { + $class = $this->getModelClass(); + /** @var class-string $className */ + $className = DiHelper::getValidatedClassName($class); + + $db = $className::getDb(); + + $transaction = $db->beginTransaction(); + + try { + /** @var Oauth2AccessTokenInterface[] $accessTokens */ + $accessTokens = $className::findAllByUserId($userId); + foreach ($accessTokens as $accessToken) { + $accessToken->setRevokedStatus(true); + $accessToken->persist(); + } + $transaction->commit(); + } catch (\Exception $e) { + $transaction->rollBack(); + throw $e; + } + + return $accessTokens; + } } diff --git a/src/components/repositories/Oauth2RefreshTokenRepository.php b/src/components/repositories/Oauth2RefreshTokenRepository.php index 578b0a9..718aa6a 100644 --- a/src/components/repositories/Oauth2RefreshTokenRepository.php +++ b/src/components/repositories/Oauth2RefreshTokenRepository.php @@ -5,7 +5,9 @@ use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use rhertogh\Yii2Oauth2Server\components\repositories\base\Oauth2BaseTokenRepository; use rhertogh\Yii2Oauth2Server\components\repositories\traits\Oauth2ModelRepositoryTrait; +use rhertogh\Yii2Oauth2Server\helpers\DiHelper; use rhertogh\Yii2Oauth2Server\interfaces\components\repositories\Oauth2RefreshTokenRepositoryInterface; +use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2AccessTokenInterface; use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2RefreshTokenInterface; class Oauth2RefreshTokenRepository extends Oauth2BaseTokenRepository implements Oauth2RefreshTokenRepositoryInterface @@ -52,4 +54,33 @@ public function isRefreshTokenRevoked($tokenId) { return static::isTokenRevoked($tokenId); } + + /** + * @inheritDoc + */ + public function revokeRefreshTokensByAccessTokenIds($accessTokenIds) + { + $class = $this->getModelClass(); + /** @var class-string $className */ + $className = DiHelper::getValidatedClassName($class); + + $db = $className::getDb(); + + $transaction = $db->beginTransaction(); + + try { + /** @var Oauth2RefreshTokenInterface[] $refreshTokens */ + $refreshTokens = $className::findAllByAccessTokenIds($accessTokenIds); + foreach ($refreshTokens as $refreshToken) { + $refreshToken->setRevokedStatus(true); + $refreshToken->persist(); + } + $transaction->commit(); + } catch (\Exception $e) { + $transaction->rollBack(); + throw $e; + } + + return $refreshTokens; + } } diff --git a/src/controllers/console/debug/Oauth2DebugConfigAction.php b/src/controllers/console/debug/Oauth2DebugConfigAction.php index f3256e2..b077574 100644 --- a/src/controllers/console/debug/Oauth2DebugConfigAction.php +++ b/src/controllers/console/debug/Oauth2DebugConfigAction.php @@ -105,6 +105,9 @@ protected function getConfiguration($module) 'clientAuthorizationView' => $module->clientAuthorizationView, 'openIdConnectUserinfoPath' => $module->openIdConnectUserinfoPath, 'openIdConnectRpInitiatedLogoutPath' => $module->openIdConnectRpInitiatedLogoutPath, + 'openIdConnectLogoutConfirmationUrl' => $module->openIdConnectLogoutConfirmationUrl, + 'openIdConnectLogoutConfirmationPath' => $module->openIdConnectLogoutConfirmationPath, + 'openIdConnectLogoutConfirmationView' => $module->openIdConnectLogoutConfirmationView, 'exceptionOnInvalidScope' => $module->exceptionOnInvalidScope ? 'true' : 'false', @@ -186,12 +189,29 @@ protected function getEndpoints($module) $oidcUserinfoValue = '[Userinfo Endpoint is disabled]'; $oidcUserinfoSettings = 'openIdConnectUserinfoEndpoint'; } + + if (!empty($module->openIdConnectRpInitiatedLogoutEndpoint)) { + if ($module->openIdConnectRpInitiatedLogoutEndpoint === true) { + $oidcRpInitiatedLogoutValue = $module->urlRulesPrefix . '/' . $module->openIdConnectRpInitiatedLogoutPath; + $oidcRpInitiatedLogoutSettings = 'urlRulesPrefix, openIdConnectRpInitiatedLogoutPath'; + } else { + $oidcRpInitiatedLogoutValue = $module->openIdConnectRpInitiatedLogoutEndpoint; + $oidcRpInitiatedLogoutSettings = 'openIdConnectRpInitiatedLogoutEndpoint'; + } + } else { + $oidcRpInitiatedLogoutValue = '[Rp Initiated Logout is disabled]'; + $oidcRpInitiatedLogoutSettings = 'openIdConnectRpInitiatedLogoutEndpoint'; + } + } else { $oidcProviderConfigInfoValue = '[OpenID Connect is disabled]'; $oidcProviderConfigInfoSettings = 'enableOpenIdConnect'; $oidcUserinfoValue = '[OpenID Connect is disabled]'; $oidcUserinfoSettings = 'enableOpenIdConnect'; + + $oidcRpInitiatedLogoutValue = '[OpenID Connect is disabled]'; + $oidcRpInitiatedLogoutSettings = 'enableOpenIdConnect'; } } else { $authorizeClientValue = '[Only available for "authorization_server" role]'; @@ -214,6 +234,9 @@ protected function getEndpoints($module) $oidcUserinfoValue = '[Only available for "authorization_server" role]'; $oidcUserinfoSettings = 'serverRole'; + + $oidcRpInitiatedLogoutValue = '[Only available for "authorization_server" role]'; + $oidcRpInitiatedLogoutSettings = 'serverRole'; } return [ @@ -228,6 +251,7 @@ protected function getEndpoints($module) $oidcProviderConfigInfoSettings, ], 'oidcUserinfo' => ['OpenId Connect Userinfo', $oidcUserinfoValue, $oidcUserinfoSettings], + 'oidcRpInitiatedLogout' => ['OpenId Connect Rp Initiated Logout', $oidcRpInitiatedLogoutValue, $oidcRpInitiatedLogoutSettings], ]; } } diff --git a/src/controllers/web/Oauth2ConsentController.php b/src/controllers/web/Oauth2ConsentController.php index e396b1c..846463f 100644 --- a/src/controllers/web/Oauth2ConsentController.php +++ b/src/controllers/web/Oauth2ConsentController.php @@ -4,6 +4,7 @@ use rhertogh\Yii2Oauth2Server\controllers\web\base\Oauth2BaseWebController; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\consent\Oauth2AuthorizeClientActionInterface; +use rhertogh\Yii2Oauth2Server\controllers\web\consent\Oauth2AuthorizeEndSessionAction; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\Oauth2ConsentControllerInterface; use yii\filters\VerbFilter; @@ -19,6 +20,7 @@ public function behaviors() 'class' => VerbFilter::class, 'actions' => [ static::ACTION_NAME_AUTHORIZE_CLIENT => ['GET', 'POST'], + static::ACTION_NAME_AUTHORIZE_END_SESSION => ['GET', 'POST'], ], ], ]; @@ -34,6 +36,10 @@ public function actions() 'class' => Oauth2AuthorizeClientActionInterface::class, 'clientAuthorizationView' => $this->module->clientAuthorizationView, ], + static::ACTION_NAME_AUTHORIZE_END_SESSION => [ + 'class' => Oauth2AuthorizeEndSessionAction::class, + 'openIdConnectLogoutConfirmationView' => $this->module->openIdConnectLogoutConfirmationView, + ], ]; } } diff --git a/src/controllers/web/consent/Oauth2AuthorizeEndSessionAction.php b/src/controllers/web/consent/Oauth2AuthorizeEndSessionAction.php new file mode 100644 index 0000000..277a395 --- /dev/null +++ b/src/controllers/web/consent/Oauth2AuthorizeEndSessionAction.php @@ -0,0 +1,63 @@ +openIdConnectLogoutConfirmationView)) { + throw new InvalidConfigException('$openIdConnectLogoutConfirmationView must be set.'); + } + } + + public function run($endSessionAuthorizationRequestId) + { + try { + $module = $this->controller->module; + + $endSessionAuthorizationRequest = $module->getEndSessionAuthReqSession($endSessionAuthorizationRequestId); + + if (empty($endSessionAuthorizationRequest)) { + throw new BadRequestHttpException(Yii::t('oauth2', 'Invalid endSessionAuthorizationRequestId.')); + } + + if ( + $endSessionAuthorizationRequest->load(Yii::$app->request->post()) + && $endSessionAuthorizationRequest->validate() + ) { + return $module->generateEndSessionAuthReqCompledRedirectResponse($endSessionAuthorizationRequest); + } + + return $this->controller->render($this->openIdConnectLogoutConfirmationView, [ + 'endSessionAuthorizationRequest' => $endSessionAuthorizationRequest, + ]); + } catch (\Exception $e) { + $message = Yii::t('oauth2', 'Unable to respond to logout authorization request.'); + if ($e instanceof HttpException) { + $message .= ' ' . $e->getMessage(); + } + throw new ServerErrorHttpException($message, 0, $e); + } + } +} diff --git a/src/controllers/web/openidconnect/Oauth2OidcEndSessionAction.php b/src/controllers/web/openidconnect/Oauth2OidcEndSessionAction.php index c3cf08b..40336ef 100644 --- a/src/controllers/web/openidconnect/Oauth2OidcEndSessionAction.php +++ b/src/controllers/web/openidconnect/Oauth2OidcEndSessionAction.php @@ -15,12 +15,15 @@ use rhertogh\Yii2Oauth2Server\controllers\web\Oauth2OidcController; use rhertogh\Yii2Oauth2Server\helpers\UrlHelper; use rhertogh\Yii2Oauth2Server\interfaces\controllers\web\openidconnect\Oauth2OidcEndSessionActionInterface; +use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\client\Oauth2ClientAuthorizationRequestInterface; +use rhertogh\Yii2Oauth2Server\interfaces\components\authorization\EndSession\Oauth2EndSessionAuthorizationRequestInterface; use rhertogh\Yii2Oauth2Server\interfaces\models\external\user\Oauth2OidcUserInterface; use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2ClientInterface; use Yii; use yii\base\InvalidConfigException; use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; +use yii\web\NotFoundHttpException; use yii\web\Response; use yii\web\UnauthorizedHttpException; @@ -34,14 +37,12 @@ class Oauth2OidcEndSessionAction extends Oauth2BaseWebAction implements Oauth2Oi * @return Response * @throws InvalidConfigException */ - public function run() + public function run($endSessionAuthorizationRequestId = null) { $module = $this->controller->module; $request = Yii::$app->request; $identity = $module->getUserIdentity(); - $logoutVerificationRequired = false; - if (!$module->enableOpenIdConnect) { throw new ForbiddenHttpException('OpenID Connect is disabled.'); } @@ -51,105 +52,50 @@ public function run() . get_class($identity) . ' must implement ' . Oauth2OidcUserInterface::class); } - $clientIdentifier = $this->getRequestParam($request, 'client_id'); - $idTokenHint = $this->getRequestParam($request, 'id_token_hint'); - $state = $this->getRequestParam($request, 'state'); - $postLogoutRedirectUri = $this->getRequestParam($request, 'post_logout_redirect_uri'); - - // The `id_token_hint` is the OIDC id token (https://openid.net/specs/openid-connect-core-1_0.html#IDToken) - if ($idTokenHint) { - - $parser = new Parser(new JoseEncoder()); - - $idToken = $parser->parse($idTokenHint); - - $validator = new Validator(); - - if (!$validator->validate($idToken, new SignedWith( - new Sha256(), - InMemory::plainText($module->getPublicKey()->getKeyContents()) - ))) { - throw new UnauthorizedHttpException('Invalid `id_token_hint` signature.'); + if (empty($endSessionAuthorizationRequestId)) { + /** @var Oauth2EndSessionAuthorizationRequestInterface $endSessionAuthorizationRequest */ + $endSessionAuthorizationRequest = Yii::createObject([ + 'class' => Oauth2EndSessionAuthorizationRequestInterface::class, + 'module' => $module, + 'idTokenHint' => $this->getRequestParam($request, 'id_token_hint'), + 'clientIdentifier' => $this->getRequestParam($request, 'client_id'), + 'endSessionUrl' => $request->absoluteUrl, + 'redirectUri' => $this->getRequestParam($request, 'post_logout_redirect_uri'), + 'state' => $this->getRequestParam($request, 'state'), + ]); + + $endSessionAuthorizationRequest->validateRequest(); + + if (!$identity) { + /** + * Specified in https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling + * "Note that because RP-Initiated Logout Requests are intended to be idempotent, + * it is explicitly not an error for an RP to request that a logout be performed when the OP does not + * consider that the End-User is logged in with the OP at the requesting RP." + */ + return $this->controller->redirect($endSessionAuthorizationRequest->getRequestCompletedRedirectUrl(true)); } - if ($clientIdentifier) { - if (!$validator->validate($idToken, new PermittedFor($clientIdentifier))) { - throw new UnauthorizedHttpException('Invalid "aud" claim in `id_token_hint`.'); - } - } else { - $audiences = $idToken->claims()->get('aud'); - if (count($audiences) === 1) { - $clientIdentifier = $audiences[0]; + if ($endSessionAuthorizationRequest->getEndUserAuthorizationRequired()) { + if ($endSessionAuthorizationRequest->isAuthorizationAllowed()) { + return $module->generateEndSessionAuthReqRedirectResponse($endSessionAuthorizationRequest); } else { - throw new BadRequestHttpException( - 'The `client_id` parameter is required when there are multiple audiences' - . ' in the "aud" claim of the `id_token_hint`.' - ); - } - } - - if ($identity) { - if (!$validator->validate($idToken, new RelatedTo((string)$identity->getIdentifier()))) { - $logoutVerificationRequired = true; + throw new UnauthorizedHttpException(Yii::t('oauth2', 'You are not allowed to authorize logging out.')); } + } else { + $endSessionAuthorizationRequest->autoApproveAndProcess(); } } else { - if (!$module->openIdConnectAllowAnonymousRpInitiatedLogout) { - throw new BadRequestHttpException('The `id_token_hint` parameter is required.'); - } - $logoutVerificationRequired = true; - } - - if ($clientIdentifier) { - $client = $module->getClientRepository()->getClientEntity($clientIdentifier); - if (!$client || !$client->isEnabled()) { - throw new ForbiddenHttpException('Client "' . $clientIdentifier . '" not found or disabled.'); - } - - if ( - !($client->getOpenIdConnectRpInitiatedLogout() - > Oauth2ClientInterface::OIDC_RP_INITIATED_LOGOUT_DISABLED) - ) { - throw new ForbiddenHttpException('Client "' . $clientIdentifier . '" is not allowed to initiated end-user logout.'); - } - } - - if (!$logoutVerificationRequired) { - if (isset($client)) { - if ( - $client->getOpenIdConnectRpInitiatedLogout() - !== Oauth2ClientInterface::OIDC_RP_INITIATED_LOGOUT_ENABLED_WITHOUT_VERIFICATION - ) { - $logoutVerificationRequired = true; - } - } else { - $logoutVerificationRequired = true; + $endSessionAuthorizationRequest = $module->getEndSessionAuthReqSession($endSessionAuthorizationRequestId); + if (empty($endSessionAuthorizationRequest)) { + throw new NotFoundHttpException('End Session authorization request not found.'); } } - if ($logoutVerificationRequired) { - throw new \LogicException('Not yet implemented: oidc_rp_initiated_logout is currently only supported without end-user verification.'); + if (!$endSessionAuthorizationRequest->isCompleted()) { + throw new BadRequestHttpException('End Session authorization is not completed.'); } - $module->logoutUser(); - - if (empty($client) || empty($postLogoutRedirectUri)) { - return $this->controller->goHome(); - } - - $allowedPostLogoutRedirectUris = $client->getPostLogoutRedirectUris(); - $validatePostLogoutRedirectUri = $postLogoutRedirectUri; - if ($client->isVariableRedirectUriQueryAllowed()) { - $validatePostLogoutRedirectUri = UrlHelper::stripQueryAndFragment($validatePostLogoutRedirectUri); - } - - $validator = new RedirectUriValidator($allowedPostLogoutRedirectUris); - if (!$validator->validateRedirectUri($validatePostLogoutRedirectUri)) { - throw new UnauthorizedHttpException('Invalid `post_logout_redirect_uri`.'); - } - - $redirectUri = UrlHelper::addQueryParams($postLogoutRedirectUri, ['state' => $state]); - - return $this->controller->redirect($redirectUri); + return $this->controller->redirect($endSessionAuthorizationRequest->getRequestCompletedRedirectUrl()); } } diff --git a/src/controllers/web/server/Oauth2RevokeAction.php b/src/controllers/web/server/Oauth2RevokeAction.php index 57dac97..37058b1 100644 --- a/src/controllers/web/server/Oauth2RevokeAction.php +++ b/src/controllers/web/server/Oauth2RevokeAction.php @@ -20,7 +20,10 @@ use yii\web\NotFoundHttpException; /** + * OAuth 2.0 Token Revocation (RFC 7009) + * * @property Oauth2ServerController $controller + * @see https://datatracker.ietf.org/doc/html/rfc7009 */ class Oauth2RevokeAction extends Oauth2BaseServerAction implements Oauth2RevokeActionInterface { diff --git a/src/interfaces/components/authorization/EndSession/Oauth2EndSessionAuthorizationRequestInterface.php b/src/interfaces/components/authorization/EndSession/Oauth2EndSessionAuthorizationRequestInterface.php new file mode 100644 index 0000000..189f86a --- /dev/null +++ b/src/interfaces/components/authorization/EndSession/Oauth2EndSessionAuthorizationRequestInterface.php @@ -0,0 +1,83 @@ +where(['access_token_id' => $accessTokenIds])->all(); + } + ///////////////////////// /// Getters & Setters /// ///////////////////////// diff --git a/src/models/Oauth2UserClient.php b/src/models/Oauth2UserClient.php index c0654c6..f82bb6f 100644 --- a/src/models/Oauth2UserClient.php +++ b/src/models/Oauth2UserClient.php @@ -4,8 +4,10 @@ use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2UserClientInterface; use rhertogh\Yii2Oauth2Server\models\traits\Oauth2EnabledTrait; +use rhertogh\Yii2Oauth2Server\models\traits\Oauth2UserIdentifierTrait; class Oauth2UserClient extends base\Oauth2UserClient implements Oauth2UserClientInterface { use Oauth2EnabledTrait; + use Oauth2UserIdentifierTrait; } diff --git a/src/models/Oauth2UserClientScope.php b/src/models/Oauth2UserClientScope.php index 1f4d658..4a15cf2 100644 --- a/src/models/Oauth2UserClientScope.php +++ b/src/models/Oauth2UserClientScope.php @@ -4,8 +4,10 @@ use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2UserClientScopeInterface; use rhertogh\Yii2Oauth2Server\models\traits\Oauth2EnabledTrait; +use rhertogh\Yii2Oauth2Server\models\traits\Oauth2UserIdentifierTrait; class Oauth2UserClientScope extends base\Oauth2UserClientScope implements Oauth2UserClientScopeInterface { use Oauth2EnabledTrait; + use Oauth2UserIdentifierTrait; } diff --git a/src/models/traits/Oauth2UserIdentifierTrait.php b/src/models/traits/Oauth2UserIdentifierTrait.php index 5d2eb31..43f2386 100644 --- a/src/models/traits/Oauth2UserIdentifierTrait.php +++ b/src/models/traits/Oauth2UserIdentifierTrait.php @@ -2,8 +2,26 @@ namespace rhertogh\Yii2Oauth2Server\models\traits; +use rhertogh\Yii2Oauth2Server\interfaces\models\base\Oauth2ActiveRecordInterface; + trait Oauth2UserIdentifierTrait { + //////////////////////// + /// Static Functions /// + //////////////////////// + + /** + * @inheritDoc + */ + public static function findAllByUserId($userId) + { + return static::find()->where(['user_id' => $userId])->all(); + } + + ///////////////////////// + /// Getters & Setters /// + ///////////////////////// + /** * @inheritDoc */ diff --git a/src/views/consent/confirm-logout.php b/src/views/consent/confirm-logout.php new file mode 100644 index 0000000..63e6c6a --- /dev/null +++ b/src/views/consent/confirm-logout.php @@ -0,0 +1,138 @@ + + +getClient(); +?> +
+
+ 'oauth2-end-session-authorization-request-form']) ?> +
+

+ Html::encode($client->getName()), + 'appName' => Yii::$app->name, + ]); + } else { + echo Yii::t('oauth2', 'Do you want to log out from {appName}?', [ + 'appName' => Yii::$app->name, + ]); + } + ?> +

+
+ + +
+
diff --git a/tests/unit/components/authorization/base/Oauth2BaseAuthorizationRequestTest.php b/tests/unit/components/authorization/base/Oauth2BaseAuthorizationRequestTest.php index d3c7a66..b989241 100644 --- a/tests/unit/components/authorization/base/Oauth2BaseAuthorizationRequestTest.php +++ b/tests/unit/components/authorization/base/Oauth2BaseAuthorizationRequestTest.php @@ -23,6 +23,7 @@ public function testSerialization() $clientIdentifier = 'client-id'; $userIdentifier = 123; $redirectUri = 'https://localhost/redirect_url'; + $state = '1234567890abc'; $authorizationStatus = Oauth2BaseAuthorizationRequestInterface::AUTHORIZATION_APPROVED; $isCompleted = true; @@ -31,6 +32,7 @@ public function testSerialization() $this->setInaccessibleProperty($baseAuthorizationRequest, '_clientIdentifier', $clientIdentifier); $this->setInaccessibleProperty($baseAuthorizationRequest, '_userIdentifier', $userIdentifier); $this->setInaccessibleProperty($baseAuthorizationRequest, '_redirectUri', $redirectUri); + $this->setInaccessibleProperty($baseAuthorizationRequest, '_state', $state); $this->setInaccessibleProperty($baseAuthorizationRequest, '_authorizationStatus', $authorizationStatus); $this->setInaccessibleProperty($baseAuthorizationRequest, '_isCompleted', $isCompleted); @@ -41,6 +43,7 @@ public function testSerialization() $this->assertEquals($clientIdentifier, $this->getInaccessibleProperty($baseAuthorizationRequest, '_clientIdentifier')); $this->assertEquals($userIdentifier, $this->getInaccessibleProperty($baseAuthorizationRequest, '_userIdentifier')); $this->assertEquals($redirectUri, $this->getInaccessibleProperty($baseAuthorizationRequest, '_redirectUri')); + $this->assertEquals($state, $this->getInaccessibleProperty($baseAuthorizationRequest, '_state')); $this->assertEquals($authorizationStatus, $this->getInaccessibleProperty($baseAuthorizationRequest, '_authorizationStatus')); $this->assertEquals($isCompleted, $this->getInaccessibleProperty($baseAuthorizationRequest, '_isCompleted')); // phpcs:enable Generic.Files.LineLength.TooLong diff --git a/tests/unit/components/authorization/client/base/Oauth2BaseClientAuthorizationRequestTest.php b/tests/unit/components/authorization/client/base/Oauth2BaseClientAuthorizationRequestTest.php index 048ea8b..694ff9d 100644 --- a/tests/unit/components/authorization/client/base/Oauth2BaseClientAuthorizationRequestTest.php +++ b/tests/unit/components/authorization/client/base/Oauth2BaseClientAuthorizationRequestTest.php @@ -15,7 +15,6 @@ public function testSerialization() { $baseClientAuthorizationRequest = $this->getMockBaseClientAuthorizationRequest(); $authorizeUrl = 'https://localhost/auth_url'; - $state = '1234567890abc'; $grantType = Oauth2Module::GRANT_TYPE_AUTH_CODE; $prompts = 'abc'; $maxAge = 1716921219; @@ -24,7 +23,6 @@ public function testSerialization() // phpcs:disable Generic.Files.LineLength.TooLong -- readability acually better on single line $this->setInaccessibleProperty($baseClientAuthorizationRequest, '_authorizeUrl', $authorizeUrl); - $this->setInaccessibleProperty($baseClientAuthorizationRequest, '_state', $state); $this->setInaccessibleProperty($baseClientAuthorizationRequest, '_grantType', $grantType); $this->setInaccessibleProperty($baseClientAuthorizationRequest, '_prompts', $prompts); $this->setInaccessibleProperty($baseClientAuthorizationRequest, '_maxAge', $maxAge); @@ -37,7 +35,6 @@ public function testSerialization() $this->assertInstanceOf(Oauth2BaseClientAuthorizationRequest::class, $baseClientAuthorizationRequest); $this->assertEquals($authorizeUrl, $this->getInaccessibleProperty($baseClientAuthorizationRequest, '_authorizeUrl')); - $this->assertEquals($state, $this->getInaccessibleProperty($baseClientAuthorizationRequest, '_state')); $this->assertEquals($grantType, $this->getInaccessibleProperty($baseClientAuthorizationRequest, '_grantType')); $this->assertEquals($prompts, $this->getInaccessibleProperty($baseClientAuthorizationRequest, '_prompts')); $this->assertEquals($maxAge, $this->getInaccessibleProperty($baseClientAuthorizationRequest, '_maxAge')); diff --git a/tests/unit/controllers/console/debug/Oauth2DebugConfigActionTest.php b/tests/unit/controllers/console/debug/Oauth2DebugConfigActionTest.php index 66caf29..d37004e 100644 --- a/tests/unit/controllers/console/debug/Oauth2DebugConfigActionTest.php +++ b/tests/unit/controllers/console/debug/Oauth2DebugConfigActionTest.php @@ -117,6 +117,8 @@ public function testGetEndpoints($moduleConfig, $overwriteExpectedEndpoints) ], 'oidcUserinfo' => ['OpenId Connect Userinfo', 'oauth2/oidc/userinfo', 'urlRulesPrefix, openIdConnectUserinfoPath'], + 'oidcRpInitiatedLogout' => + ['OpenId Connect Rp Initiated Logout', '[Rp Initiated Logout is disabled]', 'openIdConnectRpInitiatedLogoutEndpoint'], ]; $expectedEndpoints = array_merge($defaultTestEndpoints, $overwriteExpectedEndpoints); @@ -163,6 +165,8 @@ public function getEndpointsProvider() ], 'oidcUserinfo' => ['OpenId Connect Userinfo', '[Only available for "authorization_server" role]', 'serverRole'], + 'oidcRpInitiatedLogout' => + ['OpenId Connect Rp Initiated Logout', '[Only available for "authorization_server" role]', 'serverRole'], ], ], 'revocation disabled' => [ @@ -188,6 +192,8 @@ public function getEndpointsProvider() '[OpenID Connect is disabled]', 'enableOpenIdConnect', ], + 'oidcRpInitiatedLogout' => + ['OpenId Connect Rp Initiated Logout', '[OpenID Connect is disabled]', 'enableOpenIdConnect'], ], ], 'OpenID Connect Discovery disabled' => [ @@ -226,6 +232,24 @@ public function getEndpointsProvider() ], ], ], + 'OpenID Connect Rp Initiated Logout enabled' => [ + [ + 'openIdConnectRpInitiatedLogoutEndpoint' => true, + ], + [ + 'oidcRpInitiatedLogout' => + ['OpenId Connect Rp Initiated Logout', 'oauth2/oidc/end-session', 'urlRulesPrefix, openIdConnectRpInitiatedLogoutPath'], + ], + ], + 'OpenID Connect custom Rp Initiated Logout endpoint' => [ + [ + 'openIdConnectRpInitiatedLogoutEndpoint' => 'https://custom_openIdConnectEndSessionEndpoint', + ], + [ + 'oidcRpInitiatedLogout' => + ['OpenId Connect Rp Initiated Logout', 'https://custom_openIdConnectEndSessionEndpoint', 'openIdConnectRpInitiatedLogoutEndpoint'], + ], + ], ]; } } diff --git a/tests/unit/module/Oauth2ModuleTest.php b/tests/unit/module/Oauth2ModuleTest.php index d2b9aca..a5b6372 100644 --- a/tests/unit/module/Oauth2ModuleTest.php +++ b/tests/unit/module/Oauth2ModuleTest.php @@ -39,6 +39,7 @@ use yii\filters\auth\HttpBearerAuth; use yii\helpers\ReplaceArrayValue; use yii\helpers\UnsetArrayValue; +use yii\log\Logger; use yii\web\GroupUrlRule; use yii\web\IdentityInterface; use Yii2Oauth2ServerTests\_helpers\TestUserModel; @@ -943,7 +944,7 @@ public function getRequestId() } }; - $this->expectExceptionMessage('$scopeAuthorization must return a request id.'); + $this->expectExceptionMessage('$authorizationRequest must return a request id.'); $module->setClientAuthReqSession($invalidClientAuthorizationRequest); } @@ -1242,4 +1243,45 @@ function () { ], ]; } + + /** + * @param int|null $settingLevel + * @param int $expectedOutputLevel + * + * @dataProvider getElaboratedHttpClientErrorsLogLevelProvider + */ + public function testGetElaboratedHttpClientErrorsLogLevel($settingLevel, $expectedOutputLevel) + { + $this->mockWebApplication([ + 'modules' => [ + 'oauth2' => [ + 'httpClientErrorsLogLevel' => $settingLevel, + ], + ], + ]); + $module = Oauth2Module::getInstance(); + + $outputLevel = $module->getElaboratedHttpClientErrorsLogLevel(); + + $this->assertEquals($expectedOutputLevel, $outputLevel); + } + + /** + * @return array[] + * @see testGetElaboratedHttpClientErrorsLogLevel() + */ + public function getElaboratedHttpClientErrorsLogLevelProvider() + { + return [ + 'unspecified level' => [ + null, + Logger::LEVEL_ERROR, + ], + + 'specified level' => [ + Logger::LEVEL_WARNING, + Logger::LEVEL_WARNING, + ], + ]; + } }