diff --git a/appinfo/info.xml b/appinfo/info.xml index df4bdf9d1..8f0be565a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -8,7 +8,7 @@ OpenID Connect user backend Use an OpenID Connect backend to login to your Nextcloud Allows flexible configuration of an OIDC server as Nextcloud login user backend. - 7.3.2 + 7.4.0 agpl Roeland Jago Douma Julius Härtl diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b6b06fde7..a036c09a8 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -19,6 +19,7 @@ use OCA\UserOIDC\Listener\ExternalTokenRequestedListener; use OCA\UserOIDC\Listener\InternalTokenRequestedListener; use OCA\UserOIDC\Listener\TimezoneHandlingListener; +use OCA\UserOIDC\Listener\TokenInvalidatedListener; use OCA\UserOIDC\Service\ID4MeService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; @@ -59,6 +60,10 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(ExchangedTokenRequestedEvent::class, ExchangedTokenRequestedListener::class); $context->registerEventListener(ExternalTokenRequestedEvent::class, ExternalTokenRequestedListener::class); $context->registerEventListener(InternalTokenRequestedEvent::class, InternalTokenRequestedListener::class); + + if (class_exists(\OCP\Authentication\Events\TokenInvalidatedEvent::class)) { + $context->registerEventListener(\OCP\Authentication\Events\TokenInvalidatedEvent::class, TokenInvalidatedListener::class); + } } public function boot(IBootContext $context): void { diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 97c9c4247..652b4267b 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -610,12 +610,15 @@ public function code(string $state = '', string $code = '', string $scope = '', // for backchannel logout try { $authToken = $this->authTokenProvider->getToken($this->session->getId()); - $this->sessionMapper->createSession( + $this->sessionMapper->createOrUpdateSession( $idTokenPayload->sid ?? 'fallback-sid', $idTokenPayload->sub ?? 'fallback-sub', $idTokenPayload->iss ?? 'fallback-iss', $authToken->getId(), - $this->session->getId() + $this->session->getId(), + $idTokenRaw, + $user->getUID(), + $providerId, ); } catch (InvalidTokenException $e) { $this->logger->debug('Auth token not found after login'); @@ -687,7 +690,7 @@ public function singleLogoutService() { return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); } - // Check if a custom end_session_endpoint is deposited otherwise use the default one provided by the openid-configuration + // Check if a custom end_session_endpoint is set in the provider otherwise use the default one provided by the openid-configuration $discoveryData = $this->discoveryService->obtainDiscovery($provider); $defaultEndSessionEndpoint = $discoveryData['end_session_endpoint'] ?? null; $customEndSessionEndpoint = $provider->getEndSessionEndpoint(); @@ -842,8 +845,12 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok } foreach ($oidcSessionsToKill as $oidcSession) { - // i don't know why but the cast is necessary - $authTokenId = (int)$oidcSession->getAuthtokenId(); + // we know the IdP session is closed + // we need this to prevent requesting the end_session_endpoint when we catch the TokenInvalidatedEvent + $oidcSession->setIdpSessionClosed(1); + $this->sessionMapper->update($oidcSession); + + $authTokenId = $oidcSession->getAuthtokenId(); try { $authToken = $this->authTokenProvider->getTokenById($authTokenId); // we could also get the auth token by nc session ID diff --git a/lib/Db/Provider.php b/lib/Db/Provider.php index 4399b8f34..9db25fa0c 100644 --- a/lib/Db/Provider.php +++ b/lib/Db/Provider.php @@ -11,17 +11,17 @@ use OCP\AppFramework\Db\Entity; /** - * @method string getIdentifier() - * @method void setIdentifier(string $identifier) - * @method string getClientId() - * @method void setClientId(string $clientId) - * @method string getClientSecret() - * @method void setClientSecret(string $clientSecret) - * @method string getDiscoveryEndpoint() - * @method void setDiscoveryEndpoint(string $discoveryEndpoint) - * @method string getEndSessionEndpoint() - * @method void setEndSessionEndpoint(string $endSessionEndpoint) - * @method void setScope(string $scope) + * @method \string getIdentifier() + * @method \void setIdentifier(string $identifier) + * @method \string getClientId() + * @method \void setClientId(string $clientId) + * @method \string getClientSecret() + * @method \void setClientSecret(string $clientSecret) + * @method \string|\null getDiscoveryEndpoint() + * @method \void setDiscoveryEndpoint(?string $discoveryEndpoint) + * @method \string|\null getEndSessionEndpoint() + * @method \void setEndSessionEndpoint(?string $endSessionEndpoint) + * @method \void setScope(string $scope) */ class Provider extends Entity implements \JsonSerializable { @@ -34,10 +34,10 @@ class Provider extends Entity implements \JsonSerializable { /** @var string */ protected $clientSecret; - /** @var string */ + /** @var ?string */ protected $discoveryEndpoint; - /** @var string */ + /** @var ?string */ protected $endSessionEndpoint; /** @var string */ diff --git a/lib/Db/Session.php b/lib/Db/Session.php index b75e3a515..943427563 100644 --- a/lib/Db/Session.php +++ b/lib/Db/Session.php @@ -11,38 +11,49 @@ use OCP\AppFramework\Db\Entity; /** - * @method string getSid() - * @method void setSid(string $sid) - * @method string getSub() - * @method void setSub(string $sub) - * @method string getIss() - * @method void setIss(string $iss) - * @method int getAuthtokenId() - * @method void setAuthtokenId(int $authtokenId) - * @method string getNcSessionId() - * @method void setNcSessionId(string $ncSessionId) - * @method int getCreatedAt() - * @method void setCreatedAt(int $createdAt) + * @method \string getSid() + * @method \void setSid(string $sid) + * @method \string getSub() + * @method \void setSub(string $sub) + * @method \string getIss() + * @method \void setIss(string $iss) + * @method \int getAuthtokenId() + * @method \void setAuthtokenId(int $authtokenId) + * @method \string getNcSessionId() + * @method \void setNcSessionId(string $ncSessionId) + * @method \int getCreatedAt() + * @method \void setCreatedAt(int $createdAt) + * @method \string|\null getIdToken() + * @method \void setIdToken(?string $idToken) + * @method \string|\null getUserId() + * @method \void setUserId(?string $userId) + * @method \int getProviderId() + * @method \void setProviderId(int $providerId) + * @method \int getIdpSessionClosed() + * @method \void setIdpSessionClosed(int $idpSessionClosed) */ class Session extends Entity implements \JsonSerializable { /** @var string */ protected $sid; - /** @var string */ protected $sub; - /** @var string */ protected $iss; - /** @var int */ protected $authtokenId; - /** @var string */ protected $ncSessionId; - /** @var int */ protected $createdAt; + /** @var ?string */ + protected $idToken; + /** @var ?string */ + protected $userId; + /** @var int */ + protected $providerId; + /** @var int */ + protected $idpSessionClosed; public function __construct() { $this->addType('sid', 'string'); @@ -51,18 +62,25 @@ public function __construct() { $this->addType('authtoken_id', 'integer'); $this->addType('nc_session_id', 'string'); $this->addType('created_at', 'integer'); + $this->addType('id_token', 'string'); + $this->addType('user_id', 'string'); + $this->addType('provider_id', 'integer'); + $this->addType('idp_session_closed', 'integer'); } #[\ReturnTypeWillChange] public function jsonSerialize() { return [ - 'id' => $this->id, - 'sid' => $this->sid, - 'sub' => $this->sub, - 'iss' => $this->iss, - 'authtoken_id' => $this->authtokenId, - 'nc_session_id' => $this->ncSessionId, - 'created_at' => $this->createdAt, + 'id' => $this->getId(), + 'sid' => $this->getSid(), + 'sub' => $this->getSub(), + 'iss' => $this->getIss(), + 'authtoken_id' => $this->getAuthtokenId(), + 'nc_session_id' => $this->getNcSessionId(), + 'created_at' => $this->getCreatedAt(), + 'user_id' => $this->getUserId(), + 'provider_id' => $this->getProviderId(), + 'idp_session_closed' => $this->getIdpSessionClosed() !== 0, ]; } } diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php index 5089732e1..c7e3c1922 100644 --- a/lib/Db/SessionMapper.php +++ b/lib/Db/SessionMapper.php @@ -16,20 +16,25 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\Security\ICrypto; /** * @extends QBMapper */ class SessionMapper extends QBMapper { - public function __construct(IDBConnection $db) { + public function __construct( + IDBConnection $db, + private ICrypto $crypto, + ) { parent::__construct($db, 'user_oidc_sessions', Session::class); } /** * @param int $id * @return Session - * @throws \OCP\AppFramework\Db\DoesNotExistException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException */ public function getSession(int $id): Session { $qb = $this->db->getQueryBuilder(); @@ -99,10 +104,33 @@ public function findSessionBySid(string $sid, ?string $sub = null, ?string $iss return $this->findEntity($qb); } + /** + * @param int $authTokenId + * @param string $userId + * @return Session + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function getSessionByAuthTokenAndUid(int $authTokenId, string $userId): Session { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('authtoken_id', $qb->createNamedParameter($authTokenId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) + ); + + return $this->findEntity($qb); + } + /** * @param string $ncSessionId * @return int - * @throws \OCP\DB\Exception + * @throws Exception */ public function deleteFromNcSessionId(string $ncSessionId): int { $qb = $this->db->getQueryBuilder(); @@ -116,7 +144,7 @@ public function deleteFromNcSessionId(string $ncSessionId): int { /** * @param int $minCreationTimestamp - * @throws \OCP\DB\Exception + * @throws Exception */ public function cleanupSessions(int $minCreationTimestamp): void { $qb = $this->db->getQueryBuilder(); @@ -129,34 +157,62 @@ public function cleanupSessions(int $minCreationTimestamp): void { } /** - * Create a session + * Create or update a Nextcloud Oidc session + * + * We have a unique constraint on the "sid" column because there cannot be multiple Nextcloud Oidc sessions for the same IdP session (sid) + * So if we log in with an IdP session that was already used in a previous Nextcloud Oidc session, we can safely assume + * the related real Nextcloud session does not exist anymore. So we update the row for this "sid". + * + * In short: If there are multiple Nextcloud logins using the same IdP session, we only store the last one * * @param string $sid * @param string $sub * @param string $iss * @param int $authtokenId - * @param string $ncSessionid - * @return mixed|Session|\OCP\AppFramework\Db\Entity + * @param string $ncSessionId + * @param string $idToken + * @param string $userId + * @param int $providerId + * @param bool $idpSessionClosed + * @return Session|null + * @throws Exception */ - public function createSession(string $sid, string $sub, string $iss, int $authtokenId, string $ncSessionid) { + public function createOrUpdateSession( + string $sid, string $sub, string $iss, int $authtokenId, string $ncSessionId, + string $idToken, string $userId, int $providerId, bool $idpSessionClosed = false, + ): ?Session { + $createdAt = (new DateTime())->getTimestamp(); + try { // do not create if one with same sid already exists (which should not happen) - return $this->findSessionBySid($sid); + $existingSession = $this->findSessionBySid($sid); + $existingSession->setSub($sub); + $existingSession->setIss($iss); + $existingSession->setAuthtokenId($authtokenId); + $existingSession->setNcSessionId($ncSessionId); + $existingSession->setCreatedAt($createdAt); + $existingSession->setIdToken($this->crypto->encrypt($idToken)); + $existingSession->setUserId($userId); + $existingSession->setProviderId($providerId); + $existingSession->setIdpSessionClosed($idpSessionClosed ? 1 : 0); + return $this->update($existingSession); } catch (MultipleObjectsReturnedException $e) { // this can't happen return null; } catch (DoesNotExistException $e) { } - $createdAt = (new DateTime())->getTimestamp(); - $session = new Session(); $session->setSid($sid); $session->setSub($sub); $session->setIss($iss); $session->setAuthtokenId($authtokenId); - $session->setNcSessionId($ncSessionid); + $session->setNcSessionId($ncSessionId); $session->setCreatedAt($createdAt); + $session->setIdToken($this->crypto->encrypt($idToken)); + $session->setUserId($userId); + $session->setProviderId($providerId); + $session->setIdpSessionClosed($idpSessionClosed ? 1 : 0); return $this->insert($session); } } diff --git a/lib/Helper/HttpClientHelper.php b/lib/Helper/HttpClientHelper.php index 86197881d..4811041a2 100644 --- a/lib/Helper/HttpClientHelper.php +++ b/lib/Helper/HttpClientHelper.php @@ -27,8 +27,10 @@ public function get($url, array $headers = [], array $options = []) { $client = $this->clientService->newClient(); - if (isset($oidcConfig['httpclient.allowselfsigned']) - && !in_array($oidcConfig['httpclient.allowselfsigned'], [false, 'false', 0, '0'], true)) { + $debugModeEnabled = $this->config->getSystemValueBool('debug', false); + if ($debugModeEnabled + || (isset($oidcConfig['httpclient.allowselfsigned']) + && !in_array($oidcConfig['httpclient.allowselfsigned'], [false, 'false', 0, '0'], true))) { $options['verify'] = false; } diff --git a/lib/Listener/TokenInvalidatedListener.php b/lib/Listener/TokenInvalidatedListener.php new file mode 100644 index 000000000..7622dac75 --- /dev/null +++ b/lib/Listener/TokenInvalidatedListener.php @@ -0,0 +1,130 @@ + + */ +class TokenInvalidatedListener implements IEventListener { + + public function __construct( + private LoggerInterface $logger, + private SessionMapper $sessionMapper, + private ProviderMapper $providerMapper, + private DiscoveryService $discoveryService, + private IURLGenerator $urlGenerator, + private HttpClientHelper $httpClientHelper, + private ICrypto $crypto, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof TokenInvalidatedEvent) { + return; + } + + $eventToken = $event->getToken(); + $eventTokenId = $eventToken->getId(); + $eventTokenUserId = $eventToken->getUID(); + + $this->logger->debug('[TokenInvalidatedListener] received event', [ + 'token_id' => $eventTokenId, + 'user_id' => $eventTokenUserId, + ]); + + try { + $oidcSession = $this->sessionMapper->getSessionByAuthTokenAndUid($eventTokenId, $eventTokenUserId); + } catch (Exception|DoesNotExistException|MultipleObjectsReturnedException $e) { + $this->logger->warning('[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token', [ + 'token_id' => $eventTokenId, + 'user_id' => $eventTokenUserId, + 'exception' => $e, + ]); + return; + } + // we have nothing to do if we know the idp session is already closed + if ($oidcSession->getIdpSessionClosed() !== 0) { + $this->logger->warning('[TokenInvalidatedListener] The session is already closed on the IdP side', [ + 'token_id' => $eventTokenId, + 'user_id' => $eventTokenUserId, + ]); + return; + } + + // now we call the end_session_endpoint + try { + $provider = $this->providerMapper->getProvider($oidcSession->getProviderId()); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + $this->logger->warning('[TokenInvalidatedListener] Could not find the OIDC provider of a session related with an invalidated token', [ + 'token_id' => $eventTokenId, + 'user_id' => $eventTokenUserId, + 'provider_id' => $oidcSession->getProviderId(), + 'exception' => $e, + ]); + return; + } + + // Check if a custom end_session_endpoint is set in the provider otherwise use the default one provided by the openid-configuration + $discoveryData = $this->discoveryService->obtainDiscovery($provider); + $defaultEndSessionEndpoint = $discoveryData['end_session_endpoint'] ?? null; + $customEndSessionEndpoint = $provider->getEndSessionEndpoint(); + $endSessionEndpoint = $customEndSessionEndpoint ?: $defaultEndSessionEndpoint; + + if ($endSessionEndpoint === null || $endSessionEndpoint === '') { + $this->logger->warning('[TokenInvalidatedListener] Could not find the end_session_endpoint of the OIDC provider of a session related with an invalidated token', [ + 'token_id' => $eventTokenId, + 'user_id' => $eventTokenUserId, + 'provider_id' => $oidcSession->getProviderId(), + ]); + return; + } + + try { + $decryptedIdToken = $this->crypto->decrypt($oidcSession->getIdToken()); + } catch (\Exception $e) { + $this->logger->warning('[TokenInvalidatedListener] Could not decrpyt the login id token of a session related with an invalidated token', ['exception' => $e]); + return; + } + $endSessionEndpoint .= '?post_logout_redirect_uri=' . $this->urlGenerator->getAbsoluteURL('/'); + $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); + $endSessionEndpoint .= '&id_token_hint=' . $decryptedIdToken; + + $this->logger->warning('[TokenInvalidatedListener] requesting ' . $endSessionEndpoint); + try { + $this->httpClientHelper->get($endSessionEndpoint, [], ['timeout' => 5]); + } catch (ClientException|ServerException $e) { + $response = $e->getResponse(); + $body = (string)$response->getBody(); + $this->logger->debug('[TokenInvalidatedListener] Failed to request the end_session_endpoint, client or server error', [ + 'status_code' => $response->getStatusCode(), + 'body' => $body, + 'exception' => $e, + ]); + } catch (\Exception $e) { + $this->logger->debug('[TokenInvalidatedListener] Failed to request the end_session_endpoint', ['exception' => $e]); + } + // we know this oidc session is not useful anymore, we can delete it + $this->sessionMapper->delete($oidcSession); + } +} diff --git a/lib/Migration/Version070400Date20250820141709.php b/lib/Migration/Version070400Date20250820141709.php new file mode 100644 index 000000000..ec997c6bf --- /dev/null +++ b/lib/Migration/Version070400Date20250820141709.php @@ -0,0 +1,67 @@ +hasTable('user_oidc_sessions')) { + $table = $schema->getTable('user_oidc_sessions'); + if (!$table->hasColumn('id_token')) { + $table->addColumn('id_token', Types::TEXT, [ + 'notnull' => false, + ]); + $schemaChanged = true; + } + if (!$table->hasColumn('user_id')) { + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => null, + ]); + $schemaChanged = true; + } + if (!$table->hasColumn('provider_id')) { + $table->addColumn('provider_id', Types::BIGINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $schemaChanged = true; + } + if (!$table->hasColumn('idp_session_closed')) { + $table->addColumn('idp_session_closed', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $schemaChanged = true; + } + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/psalm.xml b/psalm.xml index 1d059fd4a..749664361 100644 --- a/psalm.xml +++ b/psalm.xml @@ -74,5 +74,6 @@ + diff --git a/tests/stubs/ocp_token_invalidated_event.php b/tests/stubs/ocp_token_invalidated_event.php new file mode 100644 index 000000000..6e6d962e0 --- /dev/null +++ b/tests/stubs/ocp_token_invalidated_event.php @@ -0,0 +1,12 @@ +