From 0dc537459c66b8b763a510da7d60d9ce9de9d6bf Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 17 Sep 2025 13:24:24 +0530 Subject: [PATCH 01/50] PasswordUpdatedListener: invalidate NC sessions on password change (keep current + app passwords); unconditional SSO logout --- appinfo/info.xml | 2 +- lib/Listeners/PasswordUpdatedListener.php | 38 ++++++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index dc8f67f3..daa131fb 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,7 +10,7 @@ - 12.0.0 + 12.1.0 agpl Murena SAS EcloudAccounts diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 8d1dd8f8..3b079c68 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -7,9 +7,12 @@ namespace OCA\EcloudAccounts\Listeners; use Exception; use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Service\SSOService; +use OCP\Authentication\Token\IProvider as TokenProvider; +use OCP\Authentication\Token\IToken; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\ILogger; +use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; use OCP\User\Events\PasswordUpdatedEvent; @@ -21,12 +24,16 @@ class PasswordUpdatedListener implements IEventListener { private ILogger $logger; private ISession $session; private IUserSession $userSession; + private TokenProvider $tokenProvider; + private IRequest $request; - public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession) { + public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession, TokenProvider $tokenProvider, IRequest $request) { $this->ssoService = $ssoService; $this->logger = $logger; $this->session = $session; $this->userSession = $userSession; + $this->tokenProvider = $tokenProvider; + $this->request = $request; } public function handle(Event $event): void { @@ -34,13 +41,34 @@ class PasswordUpdatedListener implements IEventListener { return; } - if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { - return; - } - $user = $event->getUser(); $username = $user->getUID(); + // Invalidate all Nextcloud sessions except the current one and app password tokens + try { + $currentToken = $this->tokenProvider->getToken($this->request); + $currentTokenId = $currentToken ? $currentToken->getId() : null; + + $tokens = $this->tokenProvider->getTokenByUser($user); + foreach ($tokens as $token) { + // Keep current interactive session + if ($currentTokenId !== null && $token->getId() === $currentTokenId) { + continue; + } + + // Keep app password tokens + if ($token->getType() === IToken::PERMANENT_TOKEN) { + continue; + } + + // Invalidate the token + $this->tokenProvider->invalidateToken($token); + } + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } + + // Also logout from SSO try { $this->ssoService->logout($username); } catch (Exception $e) { -- GitLab From fb021f5a6ec9987531dbf3ba5c301fa7fa45b053 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 17 Sep 2025 13:26:47 +0530 Subject: [PATCH 02/50] userSession changes --- lib/Listeners/PasswordUpdatedListener.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 3b079c68..47725659 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -40,7 +40,10 @@ class PasswordUpdatedListener implements IEventListener { if (!($event instanceof PasswordUpdatedEvent)) { return; } - + if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { + return; + } + $user = $event->getUser(); $username = $user->getUID(); -- GitLab From 9fe40eaeca9aa2ffb1e535ac27f4c686750e627c Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 17 Sep 2025 13:34:53 +0530 Subject: [PATCH 03/50] used session id to get token --- lib/Listeners/PasswordUpdatedListener.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 47725659..1b313d01 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -40,16 +40,17 @@ class PasswordUpdatedListener implements IEventListener { if (!($event instanceof PasswordUpdatedEvent)) { return; } + if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { return; } - + $user = $event->getUser(); $username = $user->getUID(); // Invalidate all Nextcloud sessions except the current one and app password tokens try { - $currentToken = $this->tokenProvider->getToken($this->request); + $currentToken = $this->tokenProvider->getToken($this->session->getId()); $currentTokenId = $currentToken ? $currentToken->getId() : null; $tokens = $this->tokenProvider->getTokenByUser($user); -- GitLab From 6a0efb3a55e67c6874ff3c79f2a0b9782493a0f8 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 17 Sep 2025 13:37:20 +0530 Subject: [PATCH 04/50] cleanup code --- lib/Listeners/PasswordUpdatedListener.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 1b313d01..84297f6f 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -12,7 +12,6 @@ use OCP\Authentication\Token\IToken; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\ILogger; -use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; use OCP\User\Events\PasswordUpdatedEvent; @@ -25,15 +24,13 @@ class PasswordUpdatedListener implements IEventListener { private ISession $session; private IUserSession $userSession; private TokenProvider $tokenProvider; - private IRequest $request; - public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession, TokenProvider $tokenProvider, IRequest $request) { + public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession, TokenProvider $tokenProvider) { $this->ssoService = $ssoService; $this->logger = $logger; $this->session = $session; $this->userSession = $userSession; $this->tokenProvider = $tokenProvider; - $this->request = $request; } public function handle(Event $event): void { -- GitLab From 7ab97802a7c452830fb46c60d9f44759899d71d5 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 17 Sep 2025 13:49:34 +0530 Subject: [PATCH 05/50] loggers added --- lib/Listeners/PasswordUpdatedListener.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 84297f6f..3219922e 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -49,20 +49,28 @@ class PasswordUpdatedListener implements IEventListener { try { $currentToken = $this->tokenProvider->getToken($this->session->getId()); $currentTokenId = $currentToken ? $currentToken->getId() : null; + $this->logger->debug('PasswordUpdatedListener: username={username}, currentTokenId={currentTokenId}', [ + 'username' => $username, + 'currentTokenId' => $currentTokenId ?? 'null', + ]); - $tokens = $this->tokenProvider->getTokenByUser($user); + $tokens = $this->tokenProvider->getTokenByUser($username); + $this->logger->debug('PasswordUpdatedListener: tokensFound={count}', ['count' => is_array($tokens) ? count($tokens) : 0]); foreach ($tokens as $token) { // Keep current interactive session if ($currentTokenId !== null && $token->getId() === $currentTokenId) { + $this->logger->debug('PasswordUpdatedListener: skipping current token {id}', ['id' => $token->getId()]); continue; } // Keep app password tokens if ($token->getType() === IToken::PERMANENT_TOKEN) { + $this->logger->debug('PasswordUpdatedListener: skipping app password token {id}', ['id' => $token->getId()]); continue; } // Invalidate the token + $this->logger->debug('PasswordUpdatedListener: invalidating token {id}', ['id' => $token->getId()]); $this->tokenProvider->invalidateToken($token); } } catch (Exception $e) { -- GitLab From 11391caa0429ee8d216dacd1edce7ffe35962292 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 17 Sep 2025 13:51:47 +0530 Subject: [PATCH 06/50] loggers added --- lib/Listeners/PasswordUpdatedListener.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 3219922e..1b291681 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -47,7 +47,9 @@ class PasswordUpdatedListener implements IEventListener { // Invalidate all Nextcloud sessions except the current one and app password tokens try { - $currentToken = $this->tokenProvider->getToken($this->session->getId()); + $sessionId = $this->session->getId(); + $this->logger->debug('PasswordUpdatedListener: sessionId={sessionId}', ['sessionId' => $sessionId]); + $currentToken = $this->tokenProvider->getToken($sessionId); $currentTokenId = $currentToken ? $currentToken->getId() : null; $this->logger->debug('PasswordUpdatedListener: username={username}, currentTokenId={currentTokenId}', [ 'username' => $username, -- GitLab From 8df7705a57d1209f6eb8de2e5b772c63560af099 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 18 Sep 2025 21:42:51 +0530 Subject: [PATCH 07/50] removed apppassword cond --- lib/Listeners/PasswordUpdatedListener.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 1b291681..63eb3070 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -8,7 +8,6 @@ use Exception; use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Service\SSOService; use OCP\Authentication\Token\IProvider as TokenProvider; -use OCP\Authentication\Token\IToken; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\ILogger; @@ -64,13 +63,6 @@ class PasswordUpdatedListener implements IEventListener { $this->logger->debug('PasswordUpdatedListener: skipping current token {id}', ['id' => $token->getId()]); continue; } - - // Keep app password tokens - if ($token->getType() === IToken::PERMANENT_TOKEN) { - $this->logger->debug('PasswordUpdatedListener: skipping app password token {id}', ['id' => $token->getId()]); - continue; - } - // Invalidate the token $this->logger->debug('PasswordUpdatedListener: invalidating token {id}', ['id' => $token->getId()]); $this->tokenProvider->invalidateToken($token); -- GitLab From c6a3f321a103f6d9deeb927eef94c06ff8e541ef Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 19 Sep 2025 15:38:34 +0530 Subject: [PATCH 08/50] keeping keycloak session as well for current active --- lib/Listeners/PasswordUpdatedListener.php | 36 ++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 63eb3070..28bbe0b6 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -37,45 +37,41 @@ class PasswordUpdatedListener implements IEventListener { return; } - if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { - return; - } - $user = $event->getUser(); $username = $user->getUID(); // Invalidate all Nextcloud sessions except the current one and app password tokens try { - $sessionId = $this->session->getId(); - $this->logger->debug('PasswordUpdatedListener: sessionId={sessionId}', ['sessionId' => $sessionId]); - $currentToken = $this->tokenProvider->getToken($sessionId); - $currentTokenId = $currentToken ? $currentToken->getId() : null; - $this->logger->debug('PasswordUpdatedListener: username={username}, currentTokenId={currentTokenId}', [ - 'username' => $username, - 'currentTokenId' => $currentTokenId ?? 'null', - ]); + $loggedInOidc = ($this->userSession->isLoggedIn() && $this->session->exists('is_oidc')); + $currentTokenId = null; + if ($loggedInOidc) { + $sessionId = $this->session->getId(); + $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); + if ($currentTokenId === null) { + return; + } + } $tokens = $this->tokenProvider->getTokenByUser($username); - $this->logger->debug('PasswordUpdatedListener: tokensFound={count}', ['count' => is_array($tokens) ? count($tokens) : 0]); foreach ($tokens as $token) { // Keep current interactive session if ($currentTokenId !== null && $token->getId() === $currentTokenId) { - $this->logger->debug('PasswordUpdatedListener: skipping current token {id}', ['id' => $token->getId()]); continue; } // Invalidate the token - $this->logger->debug('PasswordUpdatedListener: invalidating token {id}', ['id' => $token->getId()]); $this->tokenProvider->invalidateToken($token); } } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); } - // Also logout from SSO - try { - $this->ssoService->logout($username); - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); + // Also logout from SSO only for non-interactive reset flows e.g. Forgot password + if (!($this->userSession->isLoggedIn() && $this->session->exists('is_oidc'))) { + try { + $this->ssoService->logout($username); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } } } } -- GitLab From a64a6a367ddbaced6e2b5acc30ec6b7fe142ec69 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 19 Sep 2025 15:40:01 +0530 Subject: [PATCH 09/50] keep current session --- lib/Listeners/PasswordUpdatedListener.php | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 28bbe0b6..13eb79a2 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -40,21 +40,14 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - // Invalidate all Nextcloud sessions except the current one and app password tokens + // Invalidate all Nextcloud sessions except the current one try { - $loggedInOidc = ($this->userSession->isLoggedIn() && $this->session->exists('is_oidc')); - $currentTokenId = null; - if ($loggedInOidc) { - $sessionId = $this->session->getId(); - $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); - if ($currentTokenId === null) { - return; - } - } + $sessionId = $this->session->getId(); + $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); $tokens = $this->tokenProvider->getTokenByUser($username); foreach ($tokens as $token) { - // Keep current interactive session + // Keep current session if we could identify it if ($currentTokenId !== null && $token->getId() === $currentTokenId) { continue; } @@ -65,13 +58,5 @@ class PasswordUpdatedListener implements IEventListener { $this->logger->logException($e, ['app' => Application::APP_ID]); } - // Also logout from SSO only for non-interactive reset flows e.g. Forgot password - if (!($this->userSession->isLoggedIn() && $this->session->exists('is_oidc'))) { - try { - $this->ssoService->logout($username); - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); - } - } } } -- GitLab From c907cd324567911f2e45def7c2ffcee4f3d5f922 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Mon, 22 Sep 2025 17:55:13 +0530 Subject: [PATCH 10/50] added introspectToken --- lib/Listeners/PasswordUpdatedListener.php | 64 ++++++- lib/Service/SSOService.php | 224 ++++++++++++++++++++++ 2 files changed, 285 insertions(+), 3 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 13eb79a2..95126db0 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -40,23 +40,81 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - // Invalidate all Nextcloud sessions except the current one + $this->handleNextcloudSessions($username); + $this->handleKeycloakSessions($username); + + } + + /** + * Invalidate all Nextcloud sessions except the current one + */ + private function handleNextcloudSessions(string $username): void { try { $sessionId = $this->session->getId(); $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); + // If we cannot determine the current session token, avoid mass invalidation + if ($currentTokenId === null) { + return; + } + $tokens = $this->tokenProvider->getTokenByUser($username); foreach ($tokens as $token) { - // Keep current session if we could identify it if ($currentTokenId !== null && $token->getId() === $currentTokenId) { continue; } - // Invalidate the token $this->tokenProvider->invalidateToken($token); } } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); } + } + + /** + * Prune Keycloak sessions: keep current (interactive) or drop all (non-interactive) + */ + private function handleKeycloakSessions(string $username): void { + $isInteractive = ($this->userSession->isLoggedIn() && $this->session->exists('is_oidc')); + if ($isInteractive) { + try { + $sid = $this->resolveCurrentKeycloakSid(); + if ($sid === null) { + return; // Can't identify current KC session safely + } + $this->ssoService->logoutOtherOnlineSessionsBySid($username, $sid); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } + return; + } + + try { + $this->ssoService->logout($username); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } + } + + /** + * Resolve current Keycloak session ID (sid) from OIDC tokens in session + */ + private function resolveCurrentKeycloakSid(): ?string { + $idToken = $this->session->get('oidc.id_token'); + if (is_string($idToken)) { + $sid = $this->ssoService->getSidFromIdToken($idToken); + if (is_string($sid)) { + return $sid; + } + } + + $accessToken = $this->session->get('oidc.access_token'); + if (is_string($accessToken)) { + $introspect = $this->ssoService->introspectToken($accessToken); + if (is_array($introspect) && isset($introspect['sid']) && is_string($introspect['sid'])) { + return $introspect['sid']; + } + } + return null; } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 03acaf21..2c2655f0 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -53,6 +53,7 @@ class SSOService { $this->ssoConfig['admin_password'] = $this->config->getSystemValue('oidc_admin_password', ''); $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; $this->ssoConfig['root_url'] = $rootUrl; + $this->ssoConfig['introspect_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token/introspect'; $this->crypto = $crypto; $this->curl = $curlService; $this->logger = $logger; @@ -123,6 +124,201 @@ class SSOService { } } + /** + * Logout all online Keycloak sessions except the one matching current request context + */ + public function logoutOtherOnlineSessionsByRequestContext(string $username, string $ipAddress, ?string $userAgent = null) : void { + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } + + $sessions = $this->getOnlineSessionsForUser(); + if (empty($sessions)) { + return; + } + + $keepSessionId = $this->pickSessionByIpAndUserAgent($sessions, $ipAddress, $userAgent); + if ($keepSessionId === null) { + // Could not confidently identify the current KC session; abort to avoid disconnecting user + return; + } + + foreach ($sessions as $session) { + if (!isset($session['id'])) { + continue; + } + $sessionId = $session['id']; + if ($sessionId === $keepSessionId) { + continue; + } + $this->deleteOnlineSession($sessionId); + } + } + + /** + * Logout all online Keycloak sessions except the most recently active one + */ + public function logoutOtherOnlineSessions(string $username) : void { + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } + + $sessions = $this->getOnlineSessionsForUser(); + if (empty($sessions) || !is_array($sessions)) { + return; + } + + $keepSessionId = $this->pickMostRecentSessionId($sessions); + foreach ($sessions as $session) { + if (!isset($session['id'])) { + continue; + } + $sessionId = $session['id']; + if ($sessionId === $keepSessionId) { + continue; + } + $this->deleteOnlineSession($sessionId); + } + } + + /** + * Logout all online Keycloak sessions except the one matching the given SID + */ + public function logoutOtherOnlineSessionsBySid(string $username, string $sid) : void { + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } + + $sessions = $this->getOnlineSessionsForUser(); + if (empty($sessions)) { + return; + } + + foreach ($sessions as $session) { + if (!isset($session['id'])) { + continue; + } + $sessionId = $session['id']; + if ($sessionId === $sid) { + continue; + } + $this->deleteOnlineSession($sessionId); + } + } + + /** + * Get all online sessions for the current user from Keycloak + */ + private function getOnlineSessionsForUser(): array { + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; + try { + $sessions = $this->callSSOAPI($url, 'GET'); + return is_array($sessions) ? $sessions : []; + } catch (\Exception $e) { + $this->logger->warning('Failed to fetch online sessions for user: ' . $this->currentUserId . ' - ' . $e->getMessage()); + return []; + } + } + + /** + * Try to pick the session for the current request by matching IP and optionally User-Agent + */ + private function pickSessionByIpAndUserAgent(array $sessions, string $ipAddress, ?string $userAgent = null): ?string { + // Filter by IP first + $candidates = array_filter($sessions, function ($s) use ($ipAddress) { + return isset($s['ipAddress']) && $s['ipAddress'] === $ipAddress; + }); + + if (empty($candidates)) { + return null; + } + + // If userAgent info exists in sessions and we have a UA, try to narrow down + if (!empty($userAgent)) { + $uaCandidates = array_filter($candidates, function ($s) use ($userAgent) { + return isset($s['userAgent']) && is_string($s['userAgent']) && stripos($s['userAgent'], substr($userAgent, 0, 16)) !== false; + }); + if (!empty($uaCandidates)) { + $candidates = $uaCandidates; + } + } + + // Pick most recent among candidates + $keepSessionId = null; + $maxLastAccess = -1; + foreach ($candidates as $s) { + $lastAccess = isset($s['lastAccess']) ? intval($s['lastAccess']) : 0; + if ($lastAccess > $maxLastAccess && isset($s['id'])) { + $maxLastAccess = $lastAccess; + $keepSessionId = $s['id']; + } + } + return $keepSessionId; + } + + /** + * Pick the most recently active session ID from a list of sessions + */ + private function pickMostRecentSessionId(array $sessions): ?string { + $keepSessionId = null; + $maxLastAccess = -1; + foreach ($sessions as $s) { + if (!isset($s['id'])) { + continue; + } + $lastAccess = isset($s['lastAccess']) ? intval($s['lastAccess']) : 0; + if ($lastAccess > $maxLastAccess) { + $maxLastAccess = $lastAccess; + $keepSessionId = $s['id']; + } + } + return $keepSessionId; + } + + /** + * Delete a specific online session in Keycloak + */ + private function deleteOnlineSession(string $sessionId): void { + $url = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; + try { + $this->callSSOAPI($url, 'DELETE', [], 204); + } catch (\Exception $e) { + $this->logger->warning('Failed to delete online session ' . $sessionId . ': ' . $e->getMessage()); + } + } + + /** + * Decode a JWT without verifying signature and return payload as array + */ + public function decodeJwtWithoutVerify(string $jwt): ?array { + $parts = explode('.', $jwt); + if (count($parts) < 2) { + return null; + } + $payload = $parts[1]; + $remainder = strlen($payload) % 4; + if ($remainder) { + $payload .= str_repeat('=', 4 - $remainder); + } + $decoded = base64_decode(strtr($payload, '-_', '+/')); + if ($decoded === false) { + return null; + } + $result = json_decode($decoded, true); + return is_array($result) ? $result : null; + } + + /** + * Extract SID from an ID token + */ + public function getSidFromIdToken(string $idToken): ?string { + $payload = $this->decodeJwtWithoutVerify($idToken); + if ($payload === null) { + return null; + } + return isset($payload['sid']) && is_string($payload['sid']) ? $payload['sid'] : null; + } + /** * Revoke all offline sessions for the current user in Keycloak */ @@ -430,6 +626,34 @@ class SSOService { return $answer; } + /** + * Introspect an access token and return response (may include sid if configured) + */ + public function introspectToken(string $token): ?array { + $introspectUrl = $this->ssoConfig['introspect_url'] ?? ''; + if ($introspectUrl === '') { + return null; + } + $headers = [ + 'Content-Type: application/x-www-form-urlencoded' + ]; + $body = [ + 'token' => $token, + 'client_id' => $this->ssoConfig['admin_client_id'], + 'client_secret' => $this->ssoConfig['admin_client_secret'], + ]; + try { + $response = $this->curl->post($introspectUrl, $body, $headers); + $status = $this->curl->getLastStatusCode(); + if ($status !== 200) { + return null; + } + return json_decode($response, true); + } catch (\Exception $e) { + return null; + } + } + private function sanitizeUserName(?string $username): ?string { if (!isset($username) || is_null($username) || empty($username)) { return null; -- GitLab From 84a654b16ff89e3b86eb5bc7f4462796c4005efe Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 23 Sep 2025 16:00:39 +0530 Subject: [PATCH 11/50] revoked changes --- lib/Listeners/PasswordUpdatedListener.php | 48 +-------- lib/Service/SSOService.php | 115 +--------------------- 2 files changed, 6 insertions(+), 157 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 95126db0..3a55cb85 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -41,7 +41,7 @@ class PasswordUpdatedListener implements IEventListener { $username = $user->getUID(); $this->handleNextcloudSessions($username); - $this->handleKeycloakSessions($username); + // Keycloak session handling removed for cleanup; will re-implement separately } @@ -70,51 +70,5 @@ class PasswordUpdatedListener implements IEventListener { } } - /** - * Prune Keycloak sessions: keep current (interactive) or drop all (non-interactive) - */ - private function handleKeycloakSessions(string $username): void { - $isInteractive = ($this->userSession->isLoggedIn() && $this->session->exists('is_oidc')); - if ($isInteractive) { - try { - $sid = $this->resolveCurrentKeycloakSid(); - if ($sid === null) { - return; // Can't identify current KC session safely - } - $this->ssoService->logoutOtherOnlineSessionsBySid($username, $sid); - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); - } - return; - } - try { - $this->ssoService->logout($username); - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); - } - } - - /** - * Resolve current Keycloak session ID (sid) from OIDC tokens in session - */ - private function resolveCurrentKeycloakSid(): ?string { - $idToken = $this->session->get('oidc.id_token'); - if (is_string($idToken)) { - $sid = $this->ssoService->getSidFromIdToken($idToken); - if (is_string($sid)) { - return $sid; - } - } - - $accessToken = $this->session->get('oidc.access_token'); - if (is_string($accessToken)) { - $introspect = $this->ssoService->introspectToken($accessToken); - if (is_array($introspect) && isset($introspect['sid']) && is_string($introspect['sid'])) { - return $introspect['sid']; - } - } - - return null; - } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 2c2655f0..c695c84d 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -124,36 +124,7 @@ class SSOService { } } - /** - * Logout all online Keycloak sessions except the one matching current request context - */ - public function logoutOtherOnlineSessionsByRequestContext(string $username, string $ipAddress, ?string $userAgent = null) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - $sessions = $this->getOnlineSessionsForUser(); - if (empty($sessions)) { - return; - } - - $keepSessionId = $this->pickSessionByIpAndUserAgent($sessions, $ipAddress, $userAgent); - if ($keepSessionId === null) { - // Could not confidently identify the current KC session; abort to avoid disconnecting user - return; - } - - foreach ($sessions as $session) { - if (!isset($session['id'])) { - continue; - } - $sessionId = $session['id']; - if ($sessionId === $keepSessionId) { - continue; - } - $this->deleteOnlineSession($sessionId); - } - } + /** * Logout all online Keycloak sessions except the most recently active one @@ -181,30 +152,7 @@ class SSOService { } } - /** - * Logout all online Keycloak sessions except the one matching the given SID - */ - public function logoutOtherOnlineSessionsBySid(string $username, string $sid) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - $sessions = $this->getOnlineSessionsForUser(); - if (empty($sessions)) { - return; - } - - foreach ($sessions as $session) { - if (!isset($session['id'])) { - continue; - } - $sessionId = $session['id']; - if ($sessionId === $sid) { - continue; - } - $this->deleteOnlineSession($sessionId); - } - } + /** * Get all online sessions for the current user from Keycloak @@ -223,38 +171,7 @@ class SSOService { /** * Try to pick the session for the current request by matching IP and optionally User-Agent */ - private function pickSessionByIpAndUserAgent(array $sessions, string $ipAddress, ?string $userAgent = null): ?string { - // Filter by IP first - $candidates = array_filter($sessions, function ($s) use ($ipAddress) { - return isset($s['ipAddress']) && $s['ipAddress'] === $ipAddress; - }); - - if (empty($candidates)) { - return null; - } - - // If userAgent info exists in sessions and we have a UA, try to narrow down - if (!empty($userAgent)) { - $uaCandidates = array_filter($candidates, function ($s) use ($userAgent) { - return isset($s['userAgent']) && is_string($s['userAgent']) && stripos($s['userAgent'], substr($userAgent, 0, 16)) !== false; - }); - if (!empty($uaCandidates)) { - $candidates = $uaCandidates; - } - } - - // Pick most recent among candidates - $keepSessionId = null; - $maxLastAccess = -1; - foreach ($candidates as $s) { - $lastAccess = isset($s['lastAccess']) ? intval($s['lastAccess']) : 0; - if ($lastAccess > $maxLastAccess && isset($s['id'])) { - $maxLastAccess = $lastAccess; - $keepSessionId = $s['id']; - } - } - return $keepSessionId; - } + /** * Pick the most recently active session ID from a list of sessions @@ -290,34 +207,12 @@ class SSOService { /** * Decode a JWT without verifying signature and return payload as array */ - public function decodeJwtWithoutVerify(string $jwt): ?array { - $parts = explode('.', $jwt); - if (count($parts) < 2) { - return null; - } - $payload = $parts[1]; - $remainder = strlen($payload) % 4; - if ($remainder) { - $payload .= str_repeat('=', 4 - $remainder); - } - $decoded = base64_decode(strtr($payload, '-_', '+/')); - if ($decoded === false) { - return null; - } - $result = json_decode($decoded, true); - return is_array($result) ? $result : null; - } + /** * Extract SID from an ID token */ - public function getSidFromIdToken(string $idToken): ?string { - $payload = $this->decodeJwtWithoutVerify($idToken); - if ($payload === null) { - return null; - } - return isset($payload['sid']) && is_string($payload['sid']) ? $payload['sid'] : null; - } + /** * Revoke all offline sessions for the current user in Keycloak -- GitLab From 9e39aa604a425ed99050dccc04423abb7abf0260 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 23 Sep 2025 16:48:19 +0530 Subject: [PATCH 12/50] added attribute keycloak id --- lib/Listeners/PasswordUpdatedListener.php | 9 ++- lib/Service/SSOService.php | 88 +++++++++++------------ 2 files changed, 50 insertions(+), 47 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 3a55cb85..79294c18 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -41,10 +41,17 @@ class PasswordUpdatedListener implements IEventListener { $username = $user->getUID(); $this->handleNextcloudSessions($username); - // Keycloak session handling removed for cleanup; will re-implement separately + $this->handleKeycloakSessions($username); } + /** + * Invalidate all Keycloak sessions except the current one + */ + private function handleKeycloakSessions(string $username): void { + $this->ssoService->logoutOtherOnlineSessions($username); + } + /** * Invalidate all Nextcloud sessions except the current one */ diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index c695c84d..15fd3e4b 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -30,8 +30,7 @@ class SSOService { private TwoFactorMapper $twoFactorMapper; private ICacheFactory $cacheFactory; - private string $mainDomain; - private string $legacyDomain; + // Removed domain-based username sanitization; compare usernames directly private const ADMIN_TOKEN_ENDPOINT = '/auth/realms/master/protocol/openid-connect/token'; private const USERS_ENDPOINT = '/users'; @@ -62,8 +61,7 @@ class SSOService { $this->twoFactorMapper = $twoFactorMapper; $this->cacheFactory = $cacheFactory; - $this->mainDomain = $this->config->getSystemValue("main_domain"); - $this->legacyDomain = $this->config->getSystemValue("legacy_domain"); + // No domain normalization needed } public function shouldSync2FA() : bool { @@ -71,9 +69,7 @@ class SSOService { } public function migrateCredential(string $username, string $secret) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } + $this->setupUserId($username); $this->deleteCredentials($username); @@ -91,9 +87,7 @@ class SSOService { } public function deleteCredentials(string $username) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } + $this->setupUserId($username); $credentialIds = $this->getCredentialIds(); @@ -107,9 +101,7 @@ class SSOService { } public function logout(string $username) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } + $this->setupUserId($username); $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; @@ -130,16 +122,18 @@ class SSOService { * Logout all online Keycloak sessions except the most recently active one */ public function logoutOtherOnlineSessions(string $username) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } + $this->setupUserId($username); $sessions = $this->getOnlineSessionsForUser(); if (empty($sessions) || !is_array($sessions)) { return; } - $keepSessionId = $this->pickMostRecentSessionId($sessions); + // Prefer explicit keep id from Keycloak user attribute if configured + $keepSessionId = $this->getUserAttributeValue('nextcloud_session_id'); + if ($keepSessionId === null || $keepSessionId === '') { + $keepSessionId = $this->pickMostRecentSessionId($sessions); + } foreach ($sessions as $session) { if (!isset($session['id'])) { continue; @@ -168,11 +162,6 @@ class SSOService { } } - /** - * Try to pick the session for the current request by matching IP and optionally User-Agent - */ - - /** * Pick the most recently active session ID from a list of sessions */ @@ -205,14 +194,41 @@ class SSOService { } /** - * Decode a JWT without verifying signature and return payload as array + * Fetch Keycloak user representation */ - + private function getUserRepresentation(): array { + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; + try { + $rep = $this->callSSOAPI($url, 'GET'); + return is_array($rep) ? $rep : []; + } catch (\Exception $e) { + $this->logger->warning('Failed to fetch user representation for: ' . $this->currentUserId . ' - ' . $e->getMessage()); + return []; + } + } /** - * Extract SID from an ID token + * Get a single string value from user attributes map */ - + private function getUserAttributeValue(string $attributeName): ?string { + $rep = $this->getUserRepresentation(); + if (empty($rep) || !isset($rep['attributes']) || !is_array($rep['attributes'])) { + return null; + } + $attributes = $rep['attributes']; + if (!array_key_exists($attributeName, $attributes)) { + return null; + } + $value = $attributes[$attributeName]; + // Keycloak stores attribute values as arrays of strings + if (is_array($value) && isset($value[0]) && is_string($value[0])) { + return $value[0]; + } + if (is_string($value)) { + return $value; + } + return null; + } /** * Revoke all offline sessions for the current user in Keycloak @@ -424,8 +440,6 @@ class SSOService { $ssoUserId = ''; $ssoUserName = ''; - $username = $this->sanitizeUserName($username); - foreach($users as $ssoUser) { if (!isset($ssoUser['username']) || !isset($ssoUser['id'])) { continue; @@ -549,23 +563,5 @@ class SSOService { } } - private function sanitizeUserName(?string $username): ?string { - if (!isset($username) || is_null($username) || empty($username)) { - return null; - } - - $username = strtolower($username); - - if (str_contains($username, "@" . $this->mainDomain) || str_contains($username, "@" . $this->legacyDomain)) { - list($name, $domain) = explode("@", $username); - $username = $name; - } - - return $username; - } - private function isNotCurrentUser(string $username): bool { - $username = $this->sanitizeUserName($username); - return !(!empty($this->currentUserId) && !empty($this->currentUserName) && $username === $this->currentUserName); - } } -- GitLab From 4ca907d3f7a1762a7dcc1e6025c09bfbd267e352 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 23 Sep 2025 17:21:57 +0530 Subject: [PATCH 13/50] reverted some code --- lib/Service/SSOService.php | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 15fd3e4b..06313b10 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -30,7 +30,8 @@ class SSOService { private TwoFactorMapper $twoFactorMapper; private ICacheFactory $cacheFactory; - // Removed domain-based username sanitization; compare usernames directly + private string $mainDomain; + private string $legacyDomain; private const ADMIN_TOKEN_ENDPOINT = '/auth/realms/master/protocol/openid-connect/token'; private const USERS_ENDPOINT = '/users'; @@ -60,7 +61,8 @@ class SSOService { $this->userManager = $userManager; $this->twoFactorMapper = $twoFactorMapper; $this->cacheFactory = $cacheFactory; - + $this->mainDomain = $this->config->getSystemValue("main_domain"); + $this->legacyDomain = $this->config->getSystemValue("legacy_domain"); // No domain normalization needed } @@ -69,7 +71,9 @@ class SSOService { } public function migrateCredential(string $username, string $secret) : void { - $this->setupUserId($username); + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } $this->deleteCredentials($username); @@ -87,7 +91,9 @@ class SSOService { } public function deleteCredentials(string $username) : void { - $this->setupUserId($username); + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } $credentialIds = $this->getCredentialIds(); @@ -101,7 +107,9 @@ class SSOService { } public function logout(string $username) : void { - $this->setupUserId($username); + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; @@ -563,5 +571,24 @@ class SSOService { } } + private function sanitizeUserName(?string $username): ?string { + if (!isset($username) || is_null($username) || empty($username)) { + return null; + } + + $username = strtolower($username); + + if (str_contains($username, "@" . $this->mainDomain) || str_contains($username, "@" . $this->legacyDomain)) { + list($name, $domain) = explode("@", $username); + $username = $name; + } + + return $username; + } + + private function isNotCurrentUser(string $username): bool { + $username = $this->sanitizeUserName($username); + return !(!empty($this->currentUserId) && !empty($this->currentUserName) && $username === $this->currentUserName); + } } -- GitLab From bfb79ab32dd5424df77eeed1a130c100df83b753 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 23 Sep 2025 17:22:42 +0530 Subject: [PATCH 14/50] reverted some code --- lib/Service/SSOService.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 06313b10..8268ec0e 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -448,6 +448,8 @@ class SSOService { $ssoUserId = ''; $ssoUserName = ''; + $username = $this->sanitizeUserName($username); + foreach($users as $ssoUser) { if (!isset($ssoUser['username']) || !isset($ssoUser['id'])) { continue; -- GitLab From ff791ff715423663246ccfe02885d3fdecdf9ef8 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 23 Sep 2025 17:23:38 +0530 Subject: [PATCH 15/50] reverted some code --- lib/Service/SSOService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 8268ec0e..42df8c1e 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -61,9 +61,10 @@ class SSOService { $this->userManager = $userManager; $this->twoFactorMapper = $twoFactorMapper; $this->cacheFactory = $cacheFactory; + $this->mainDomain = $this->config->getSystemValue("main_domain"); $this->legacyDomain = $this->config->getSystemValue("legacy_domain"); - // No domain normalization needed + } public function shouldSync2FA() : bool { @@ -449,7 +450,6 @@ class SSOService { $ssoUserId = ''; $ssoUserName = ''; $username = $this->sanitizeUserName($username); - foreach($users as $ssoUser) { if (!isset($ssoUser['username']) || !isset($ssoUser['id'])) { continue; -- GitLab From 0e2d6644353558bac6470d52c74f80f20b32afb4 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 23 Sep 2025 18:02:42 +0530 Subject: [PATCH 16/50] accesstoken --- lib/Listeners/PasswordUpdatedListener.php | 26 ++++++++++++- lib/Service/SSOService.php | 46 +++-------------------- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 79294c18..5ff18c2b 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -49,7 +49,31 @@ class PasswordUpdatedListener implements IEventListener { * Invalidate all Keycloak sessions except the current one */ private function handleKeycloakSessions(string $username): void { - $this->ssoService->logoutOtherOnlineSessions($username); + try { + $sid = $this->getCurrentKeycloakSessionId(); + $this->ssoService->logoutOtherOnlineSessions($username, $sid); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } + } + + private function getCurrentKeycloakSessionId(): ?string { + // The current Keycloak session id (sid) should be present in the access token if configured. + // We attempt to read the access token from the current session token (if available) and introspect it. + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + if ($token === null) { + return null; + } + $tokenData = method_exists($token, 'getPassword') ? $token->getPassword() : null; + if (!is_string($tokenData) || $tokenData === '') { + return null; + } + $introspection = $this->ssoService->introspectToken($tokenData); + if (!is_array($introspection)) { + return null; + } + return $introspection['sid'] ?? null; } /** diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 42df8c1e..f922d388 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -128,20 +128,19 @@ class SSOService { /** - * Logout all online Keycloak sessions except the most recently active one + * Logout all online Keycloak sessions except the one matching the provided sid + * If no sid provided, do nothing (to avoid logging out the wrong session). */ - public function logoutOtherOnlineSessions(string $username) : void { + public function logoutOtherOnlineSessions(string $username, ?string $keepSessionId = null) : void { $this->setupUserId($username); $sessions = $this->getOnlineSessionsForUser(); if (empty($sessions) || !is_array($sessions)) { return; } - - // Prefer explicit keep id from Keycloak user attribute if configured - $keepSessionId = $this->getUserAttributeValue('nextcloud_session_id'); if ($keepSessionId === null || $keepSessionId === '') { - $keepSessionId = $this->pickMostRecentSessionId($sessions); + // Without a known sid, it's safer to skip rather than guess + return; } foreach ($sessions as $session) { if (!isset($session['id'])) { @@ -202,42 +201,7 @@ class SSOService { } } - /** - * Fetch Keycloak user representation - */ - private function getUserRepresentation(): array { - $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; - try { - $rep = $this->callSSOAPI($url, 'GET'); - return is_array($rep) ? $rep : []; - } catch (\Exception $e) { - $this->logger->warning('Failed to fetch user representation for: ' . $this->currentUserId . ' - ' . $e->getMessage()); - return []; - } - } - /** - * Get a single string value from user attributes map - */ - private function getUserAttributeValue(string $attributeName): ?string { - $rep = $this->getUserRepresentation(); - if (empty($rep) || !isset($rep['attributes']) || !is_array($rep['attributes'])) { - return null; - } - $attributes = $rep['attributes']; - if (!array_key_exists($attributeName, $attributes)) { - return null; - } - $value = $attributes[$attributeName]; - // Keycloak stores attribute values as arrays of strings - if (is_array($value) && isset($value[0]) && is_string($value[0])) { - return $value[0]; - } - if (is_string($value)) { - return $value; - } - return null; - } /** * Revoke all offline sessions for the current user in Keycloak -- GitLab From 7487f7ef9af9b5b0d2059099e8967db4497f2138 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 24 Sep 2025 15:33:52 +0530 Subject: [PATCH 17/50] code changes --- lib/Listeners/PasswordUpdatedListener.php | 4 +-- lib/Service/SSOService.php | 36 ++++++++++++++--------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 5ff18c2b..1d621cf5 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -65,7 +65,7 @@ class PasswordUpdatedListener implements IEventListener { if ($token === null) { return null; } - $tokenData = method_exists($token, 'getPassword') ? $token->getPassword() : null; + $tokenData = $token->getPassword(); if (!is_string($tokenData) || $tokenData === '') { return null; } @@ -94,7 +94,7 @@ class PasswordUpdatedListener implements IEventListener { if ($currentTokenId !== null && $token->getId() === $currentTokenId) { continue; } - $this->tokenProvider->invalidateToken($token); + $this->tokenProvider->invalidateToken((string) $token->getId()); } } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index f922d388..368cf76b 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -87,7 +87,7 @@ class SSOService { 'credentials' => [$credentialEntry] ]; - $this->logger->debug('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); + $this->logger->error('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); $this->callSSOAPI($url, 'PUT', $data, 204); } @@ -102,7 +102,7 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); $url .= '/' . $credentialId; - $this->logger->debug('deleteCredentials calling SSO API with url: '. $url); + $this->logger->error('deleteCredentials calling SSO API with url: '. $url); $this->callSSOAPI($url, 'DELETE', [], 204); } } @@ -114,7 +114,7 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; - $this->logger->debug('logout calling SSO API with url: '. $url); + $this->logger->error('logout calling SSO API with url: '. $url); $this->callSSOAPI($url, 'POST', [], 204); // Revoke offline sessions for all clients @@ -139,8 +139,7 @@ class SSOService { return; } if ($keepSessionId === null || $keepSessionId === '') { - // Without a known sid, it's safer to skip rather than guess - return; + $keepSessionId = $this->pickMostRecentSessionId($sessions); } foreach ($sessions as $session) { if (!isset($session['id'])) { @@ -238,7 +237,7 @@ class SSOService { $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); $cachedClients = $clientsCache->get($cacheKey); if ($cachedClients !== null) { - $this->logger->debug('Returning cached clients from distributed cache'); + $this->logger->error('Returning cached clients from distributed cache'); return $cachedClients; } @@ -272,7 +271,7 @@ class SSOService { // Get offline sessions for this client and user $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); if (empty($offlineSessions)) { - $this->logger->debug('No offline sessions found for client: ' . $clientUUID); + $this->logger->error('No offline sessions found for client: ' . $clientUUID); return; } @@ -335,7 +334,7 @@ class SSOService { private function getCredentialIds() : array { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); - $this->logger->debug('getCredentialIds calling SSO API with url: '. $url); + $this->logger->error('getCredentialIds calling SSO API with url: '. $url); $credentials = $this->callSSOAPI($url, 'GET'); @@ -405,7 +404,7 @@ class SSOService { } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&email=' . $email; - $this->logger->debug('getUserId calling SSO API with url: '. $url); + $this->logger->error('getUserId calling SSO API with url: '. $url); $users = $this->callSSOAPI($url, 'GET'); if (empty($users) || !is_array($users) || !isset($users[0])) { throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); @@ -413,7 +412,7 @@ class SSOService { $ssoUserId = ''; $ssoUserName = ''; - $username = $this->sanitizeUserName($username); + foreach($users as $ssoUser) { if (!isset($ssoUser['username']) || !isset($ssoUser['id'])) { continue; @@ -456,7 +455,7 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; - $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); + $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); if ($this->curl->getLastStatusCode() !== 200) { @@ -500,13 +499,17 @@ class SSOService { } $statusCode = $this->curl->getLastStatusCode(); + $this->logger->debug('SSO API Response status: ' . $statusCode . ' for ' . $method . ' ' . $url); if ($statusCode !== $expectedStatusCode) { throw new SSOAdminAPIException('Error calling SSO API with url ' . $url . ' status code: ' . $statusCode); } - $answer = json_decode($answer, true); - return $answer; + $decoded = json_decode($answer, true); + if (is_array($decoded)) { + return $decoded; + } + return json_decode($answer, true); } /** @@ -528,10 +531,15 @@ class SSOService { try { $response = $this->curl->post($introspectUrl, $body, $headers); $status = $this->curl->getLastStatusCode(); + $this->logger->debug('Introspect Response status: ' . $status . ' for POST ' . $introspectUrl); if ($status !== 200) { return null; } - return json_decode($response, true); + $decoded = json_decode($response, true); + if (is_array($decoded)) { + return $decoded; + } + return $decoded; } catch (\Exception $e) { return null; } -- GitLab From 5dcce4b2628180fc6cc404709cd942ff87501502 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 12:04:55 +0530 Subject: [PATCH 18/50] code implemntation --- lib/Listeners/PasswordUpdatedListener.php | 12 +++------ lib/Service/SSOService.php | 31 ++++++++++++++++++++--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 1d621cf5..2235e6a7 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -60,16 +60,11 @@ class PasswordUpdatedListener implements IEventListener { private function getCurrentKeycloakSessionId(): ?string { // The current Keycloak session id (sid) should be present in the access token if configured. // We attempt to read the access token from the current session token (if available) and introspect it. - $sessionId = $this->session->getId(); - $token = $this->tokenProvider->getToken($sessionId); - if ($token === null) { + $accessToken = $this->session->get('oidc_access_token'); + if (!is_string($accessToken) || $accessToken === '') { return null; } - $tokenData = $token->getPassword(); - if (!is_string($tokenData) || $tokenData === '') { - return null; - } - $introspection = $this->ssoService->introspectToken($tokenData); + $introspection = $this->ssoService->introspectToken($accessToken); if (!is_array($introspection)) { return null; } @@ -82,6 +77,7 @@ class PasswordUpdatedListener implements IEventListener { private function handleNextcloudSessions(string $username): void { try { $sessionId = $this->session->getId(); + $this->logger->debug('PasswordUpdatedListener: current session ID (handleNextcloudSessions) ' . $sessionId, ['app' => Application::APP_ID]); $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); // If we cannot determine the current session token, avoid mass invalidation diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 368cf76b..841ef263 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -54,6 +54,7 @@ class SSOService { $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; $this->ssoConfig['root_url'] = $rootUrl; $this->ssoConfig['introspect_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token/introspect'; + $this->ssoConfig['admin_token_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token'; $this->crypto = $crypto; $this->curl = $curlService; $this->logger = $logger; @@ -455,8 +456,15 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; - + $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); + try { + $status = $this->curl->getLastStatusCode(); + $respPreview = is_string($response) ? substr($response, 0, 2000) : print_r($response, true); + $this->logger->error('getAdminAccessToken response for POST ' . $adminAccessTokenRoute . ' status: ' . $status . ' body: ' . $respPreview); + } catch (\Throwable $e) { + // ignore logging errors + } if ($this->curl->getLastStatusCode() !== 200) { $statusCode = strval($this->curl->getLastStatusCode()); @@ -482,6 +490,16 @@ class SSOService { "Authorization: Bearer " . $this->adminAccessToken ]; + // Log request (mask Authorization) + try { + $logHeaders = array_map(function ($h) { + return (stripos($h, 'Authorization: Bearer ') === 0) ? 'Authorization: Bearer ***' : $h; + }, $headers); + $this->logger->error('SSO API Request -> ' . $method . ' ' . $url . ' headers: ' . print_r($logHeaders, true) . ' payload: ' . print_r($data, true)); + } catch (\Throwable $e) { + // ignore logging errors + } + if ($method === 'GET') { $answer = $this->curl->get($url, $data, $headers); } @@ -499,7 +517,12 @@ class SSOService { } $statusCode = $this->curl->getLastStatusCode(); - $this->logger->debug('SSO API Response status: ' . $statusCode . ' for ' . $method . ' ' . $url); + try { + $answerPreview = isset($answer) ? (is_string($answer) ? substr($answer, 0, 2000) : print_r($answer, true)) : ''; + $this->logger->error('SSO API Response <- ' . $method . ' ' . $url . ' status: ' . $statusCode . ' body: ' . $answerPreview); + } catch (\Throwable $e) { + $this->logger->error('SSO API Response status: ' . $statusCode . ' for ' . $method . ' ' . $url); + } if ($statusCode !== $expectedStatusCode) { throw new SSOAdminAPIException('Error calling SSO API with url ' . $url . ' status code: ' . $statusCode); @@ -529,9 +552,11 @@ class SSOService { 'client_secret' => $this->ssoConfig['admin_client_secret'], ]; try { + $this->logger->error('Introspect Request -> POST ' . $introspectUrl . ' headers: ' . print_r($headers, true) . ' payload: ' . print_r(['token' => '***', 'client_id' => $body['client_id'], 'client_secret' => '***'], true)); $response = $this->curl->post($introspectUrl, $body, $headers); $status = $this->curl->getLastStatusCode(); - $this->logger->debug('Introspect Response status: ' . $status . ' for POST ' . $introspectUrl); + $respPreview = is_string($response) ? substr($response, 0, 2000) : print_r($response, true); + $this->logger->error('Introspect Response <- POST ' . $introspectUrl . ' status: ' . $status . ' body: ' . $respPreview); if ($status !== 200) { return null; } -- GitLab From 000adbf1a27c7ffb2e13ca0b1fe512bbcb8c5017 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 12:06:55 +0530 Subject: [PATCH 19/50] code implemntation --- lib/Listeners/PasswordUpdatedListener.php | 1 - lib/Service/SSOService.php | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 2235e6a7..bbcf43d7 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -77,7 +77,6 @@ class PasswordUpdatedListener implements IEventListener { private function handleNextcloudSessions(string $username): void { try { $sessionId = $this->session->getId(); - $this->logger->debug('PasswordUpdatedListener: current session ID (handleNextcloudSessions) ' . $sessionId, ['app' => Application::APP_ID]); $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); // If we cannot determine the current session token, avoid mass invalidation diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 841ef263..b289ed89 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -88,7 +88,6 @@ class SSOService { 'credentials' => [$credentialEntry] ]; - $this->logger->error('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); $this->callSSOAPI($url, 'PUT', $data, 204); } @@ -103,7 +102,6 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); $url .= '/' . $credentialId; - $this->logger->error('deleteCredentials calling SSO API with url: '. $url); $this->callSSOAPI($url, 'DELETE', [], 204); } } @@ -115,7 +113,6 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; - $this->logger->error('logout calling SSO API with url: '. $url); $this->callSSOAPI($url, 'POST', [], 204); // Revoke offline sessions for all clients @@ -238,7 +235,6 @@ class SSOService { $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); $cachedClients = $clientsCache->get($cacheKey); if ($cachedClients !== null) { - $this->logger->error('Returning cached clients from distributed cache'); return $cachedClients; } @@ -246,7 +242,6 @@ class SSOService { $clients = $this->callSSOAPI($clientsUrl, 'GET'); if (!is_array($clients)) { - $this->logger->error('Could not fetch clients for offline session revocation.'); return []; } @@ -272,7 +267,6 @@ class SSOService { // Get offline sessions for this client and user $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); if (empty($offlineSessions)) { - $this->logger->error('No offline sessions found for client: ' . $clientUUID); return; } @@ -335,7 +329,6 @@ class SSOService { private function getCredentialIds() : array { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); - $this->logger->error('getCredentialIds calling SSO API with url: '. $url); $credentials = $this->callSSOAPI($url, 'GET'); @@ -405,7 +398,6 @@ class SSOService { } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&email=' . $email; - $this->logger->error('getUserId calling SSO API with url: '. $url); $users = $this->callSSOAPI($url, 'GET'); if (empty($users) || !is_array($users) || !isset($users[0])) { throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); @@ -456,12 +448,10 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; - $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); try { $status = $this->curl->getLastStatusCode(); $respPreview = is_string($response) ? substr($response, 0, 2000) : print_r($response, true); - $this->logger->error('getAdminAccessToken response for POST ' . $adminAccessTokenRoute . ' status: ' . $status . ' body: ' . $respPreview); } catch (\Throwable $e) { // ignore logging errors } @@ -495,7 +485,6 @@ class SSOService { $logHeaders = array_map(function ($h) { return (stripos($h, 'Authorization: Bearer ') === 0) ? 'Authorization: Bearer ***' : $h; }, $headers); - $this->logger->error('SSO API Request -> ' . $method . ' ' . $url . ' headers: ' . print_r($logHeaders, true) . ' payload: ' . print_r($data, true)); } catch (\Throwable $e) { // ignore logging errors } @@ -519,7 +508,6 @@ class SSOService { $statusCode = $this->curl->getLastStatusCode(); try { $answerPreview = isset($answer) ? (is_string($answer) ? substr($answer, 0, 2000) : print_r($answer, true)) : ''; - $this->logger->error('SSO API Response <- ' . $method . ' ' . $url . ' status: ' . $statusCode . ' body: ' . $answerPreview); } catch (\Throwable $e) { $this->logger->error('SSO API Response status: ' . $statusCode . ' for ' . $method . ' ' . $url); } @@ -552,11 +540,9 @@ class SSOService { 'client_secret' => $this->ssoConfig['admin_client_secret'], ]; try { - $this->logger->error('Introspect Request -> POST ' . $introspectUrl . ' headers: ' . print_r($headers, true) . ' payload: ' . print_r(['token' => '***', 'client_id' => $body['client_id'], 'client_secret' => '***'], true)); $response = $this->curl->post($introspectUrl, $body, $headers); $status = $this->curl->getLastStatusCode(); $respPreview = is_string($response) ? substr($response, 0, 2000) : print_r($response, true); - $this->logger->error('Introspect Response <- POST ' . $introspectUrl . ' status: ' . $status . ' body: ' . $respPreview); if ($status !== 200) { return null; } -- GitLab From 0e5c84fa3bd0c49404f022ea484f663ce61ddca9 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 12:16:33 +0530 Subject: [PATCH 20/50] code update --- lib/Listeners/PasswordUpdatedListener.php | 41 ++--- lib/Service/SSOService.php | 181 ++++++++-------------- 2 files changed, 72 insertions(+), 150 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index bbcf43d7..62a0094c 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -37,40 +37,26 @@ class PasswordUpdatedListener implements IEventListener { return; } + if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { + return; + } + $user = $event->getUser(); $username = $user->getUID(); - $this->handleNextcloudSessions($username); - $this->handleKeycloakSessions($username); - - } - - /** - * Invalidate all Keycloak sessions except the current one - */ - private function handleKeycloakSessions(string $username): void { + // Keycloak: logout other online sessions except current one try { - $sid = $this->getCurrentKeycloakSessionId(); + $accessToken = $this->session->get('oidc_access_token'); + $sid = is_string($accessToken) ? $this->ssoService->getSidFromAccessToken($accessToken) : null; $this->ssoService->logoutOtherOnlineSessions($username, $sid); } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); } - } - private function getCurrentKeycloakSessionId(): ?string { - // The current Keycloak session id (sid) should be present in the access token if configured. - // We attempt to read the access token from the current session token (if available) and introspect it. - $accessToken = $this->session->get('oidc_access_token'); - if (!is_string($accessToken) || $accessToken === '') { - return null; - } - $introspection = $this->ssoService->introspectToken($accessToken); - if (!is_array($introspection)) { - return null; - } - return $introspection['sid'] ?? null; + // Nextcloud: logout all sessions except the current one + $this->handleNextcloudSessions($username); } - + /** * Invalidate all Nextcloud sessions except the current one */ @@ -78,15 +64,12 @@ class PasswordUpdatedListener implements IEventListener { try { $sessionId = $this->session->getId(); $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); - - // If we cannot determine the current session token, avoid mass invalidation if ($currentTokenId === null) { return; } - $tokens = $this->tokenProvider->getTokenByUser($username); foreach ($tokens as $token) { - if ($currentTokenId !== null && $token->getId() === $currentTokenId) { + if ($token->getId() === $currentTokenId) { continue; } $this->tokenProvider->invalidateToken((string) $token->getId()); @@ -95,6 +78,4 @@ class PasswordUpdatedListener implements IEventListener { $this->logger->logException($e, ['app' => Application::APP_ID]); } } - - } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index b289ed89..c9b01e26 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -53,8 +53,6 @@ class SSOService { $this->ssoConfig['admin_password'] = $this->config->getSystemValue('oidc_admin_password', ''); $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; $this->ssoConfig['root_url'] = $rootUrl; - $this->ssoConfig['introspect_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token/introspect'; - $this->ssoConfig['admin_token_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token'; $this->crypto = $crypto; $this->curl = $curlService; $this->logger = $logger; @@ -62,10 +60,9 @@ class SSOService { $this->userManager = $userManager; $this->twoFactorMapper = $twoFactorMapper; $this->cacheFactory = $cacheFactory; - + $this->mainDomain = $this->config->getSystemValue("main_domain"); $this->legacyDomain = $this->config->getSystemValue("legacy_domain"); - } public function shouldSync2FA() : bool { @@ -88,6 +85,7 @@ class SSOService { 'credentials' => [$credentialEntry] ]; + $this->logger->debug('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); $this->callSSOAPI($url, 'PUT', $data, 204); } @@ -102,6 +100,7 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); $url .= '/' . $credentialId; + $this->logger->debug('deleteCredentials calling SSO API with url: '. $url); $this->callSSOAPI($url, 'DELETE', [], 204); } } @@ -113,6 +112,7 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; + $this->logger->debug('logout calling SSO API with url: '. $url); $this->callSSOAPI($url, 'POST', [], 204); // Revoke offline sessions for all clients @@ -123,83 +123,74 @@ class SSOService { } } - - /** - * Logout all online Keycloak sessions except the one matching the provided sid - * If no sid provided, do nothing (to avoid logging out the wrong session). + * Extract Keycloak session id (sid) from a JWT access token without remote introspection */ - public function logoutOtherOnlineSessions(string $username, ?string $keepSessionId = null) : void { - $this->setupUserId($username); - - $sessions = $this->getOnlineSessionsForUser(); - if (empty($sessions) || !is_array($sessions)) { - return; + public function getSidFromAccessToken(string $accessToken): ?string { + if (!is_string($accessToken) || $accessToken === '') { + return null; } - if ($keepSessionId === null || $keepSessionId === '') { - $keepSessionId = $this->pickMostRecentSessionId($sessions); + $parts = explode('.', $accessToken); + if (count($parts) < 2) { + return null; } - foreach ($sessions as $session) { - if (!isset($session['id'])) { - continue; - } - $sessionId = $session['id']; - if ($sessionId === $keepSessionId) { - continue; - } - $this->deleteOnlineSession($sessionId); + $payloadJson = $this->base64UrlDecode($parts[1]); + if ($payloadJson === null) { + return null; } + $payload = json_decode($payloadJson, true); + if (!is_array($payload)) { + return null; + } + return isset($payload['sid']) && is_string($payload['sid']) ? $payload['sid'] : null; } - - /** - * Get all online sessions for the current user from Keycloak + * Logout all Keycloak online sessions for the user except the provided current sid */ - private function getOnlineSessionsForUser(): array { - $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; - try { - $sessions = $this->callSSOAPI($url, 'GET'); - return is_array($sessions) ? $sessions : []; - } catch (\Exception $e) { - $this->logger->warning('Failed to fetch online sessions for user: ' . $this->currentUserId . ' - ' . $e->getMessage()); - return []; + public function logoutOtherOnlineSessions(string $username, ?string $currentSid): void { + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); } - } - /** - * Pick the most recently active session ID from a list of sessions - */ - private function pickMostRecentSessionId(array $sessions): ?string { - $keepSessionId = null; - $maxLastAccess = -1; - foreach ($sessions as $s) { - if (!isset($s['id'])) { - continue; + try { + $sessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; + $this->logger->debug('logoutOtherOnlineSessions fetching sessions for user: ' . $this->currentUserId); + $sessions = $this->callSSOAPI($sessionsUrl, 'GET'); + if (!is_array($sessions) || empty($sessions)) { + return; } - $lastAccess = isset($s['lastAccess']) ? intval($s['lastAccess']) : 0; - if ($lastAccess > $maxLastAccess) { - $maxLastAccess = $lastAccess; - $keepSessionId = $s['id']; + + foreach ($sessions as $session) { + if (!isset($session['id'])) { + continue; + } + $sessionId = (string)$session['id']; + if ($currentSid !== null && $sessionId === $currentSid) { + continue; // preserve current session + } + $deleteUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; + try { + $this->callSSOAPI($deleteUrl, 'DELETE', [], 204); + } catch (\Exception $e) { + $this->logger->warning('Failed to delete session ' . $sessionId . ': ' . $e->getMessage()); + } } + } catch (\Exception $e) { + $this->logger->warning('Failed to fetch or delete sessions for user ' . $this->currentUserId . ': ' . $e->getMessage()); } - return $keepSessionId; } - /** - * Delete a specific online session in Keycloak - */ - private function deleteOnlineSession(string $sessionId): void { - $url = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; - try { - $this->callSSOAPI($url, 'DELETE', [], 204); - } catch (\Exception $e) { - $this->logger->warning('Failed to delete online session ' . $sessionId . ': ' . $e->getMessage()); + private function base64UrlDecode(string $input): ?string { + $remainder = strlen($input) % 4; + if ($remainder > 0) { + $input .= str_repeat('=', 4 - $remainder); } + $decoded = base64_decode(strtr($input, '-_', '+/'), true); + return $decoded === false ? null : $decoded; } - /** * Revoke all offline sessions for the current user in Keycloak */ @@ -235,6 +226,7 @@ class SSOService { $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); $cachedClients = $clientsCache->get($cacheKey); if ($cachedClients !== null) { + $this->logger->debug('Returning cached clients from distributed cache'); return $cachedClients; } @@ -242,6 +234,7 @@ class SSOService { $clients = $this->callSSOAPI($clientsUrl, 'GET'); if (!is_array($clients)) { + $this->logger->error('Could not fetch clients for offline session revocation.'); return []; } @@ -267,6 +260,7 @@ class SSOService { // Get offline sessions for this client and user $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); if (empty($offlineSessions)) { + $this->logger->debug('No offline sessions found for client: ' . $clientUUID); return; } @@ -329,6 +323,7 @@ class SSOService { private function getCredentialIds() : array { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); + $this->logger->debug('getCredentialIds calling SSO API with url: '. $url); $credentials = $this->callSSOAPI($url, 'GET'); @@ -398,6 +393,7 @@ class SSOService { } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&email=' . $email; + $this->logger->debug('getUserId calling SSO API with url: '. $url); $users = $this->callSSOAPI($url, 'GET'); if (empty($users) || !is_array($users) || !isset($users[0])) { throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); @@ -405,6 +401,7 @@ class SSOService { $ssoUserId = ''; $ssoUserName = ''; + $username = $this->sanitizeUserName($username); foreach($users as $ssoUser) { if (!isset($ssoUser['username']) || !isset($ssoUser['id'])) { @@ -448,13 +445,8 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; + $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); - try { - $status = $this->curl->getLastStatusCode(); - $respPreview = is_string($response) ? substr($response, 0, 2000) : print_r($response, true); - } catch (\Throwable $e) { - // ignore logging errors - } if ($this->curl->getLastStatusCode() !== 200) { $statusCode = strval($this->curl->getLastStatusCode()); @@ -480,15 +472,6 @@ class SSOService { "Authorization: Bearer " . $this->adminAccessToken ]; - // Log request (mask Authorization) - try { - $logHeaders = array_map(function ($h) { - return (stripos($h, 'Authorization: Bearer ') === 0) ? 'Authorization: Bearer ***' : $h; - }, $headers); - } catch (\Throwable $e) { - // ignore logging errors - } - if ($method === 'GET') { $answer = $this->curl->get($url, $data, $headers); } @@ -506,54 +489,13 @@ class SSOService { } $statusCode = $this->curl->getLastStatusCode(); - try { - $answerPreview = isset($answer) ? (is_string($answer) ? substr($answer, 0, 2000) : print_r($answer, true)) : ''; - } catch (\Throwable $e) { - $this->logger->error('SSO API Response status: ' . $statusCode . ' for ' . $method . ' ' . $url); - } if ($statusCode !== $expectedStatusCode) { throw new SSOAdminAPIException('Error calling SSO API with url ' . $url . ' status code: ' . $statusCode); } - $decoded = json_decode($answer, true); - if (is_array($decoded)) { - return $decoded; - } - return json_decode($answer, true); - } - - /** - * Introspect an access token and return response (may include sid if configured) - */ - public function introspectToken(string $token): ?array { - $introspectUrl = $this->ssoConfig['introspect_url'] ?? ''; - if ($introspectUrl === '') { - return null; - } - $headers = [ - 'Content-Type: application/x-www-form-urlencoded' - ]; - $body = [ - 'token' => $token, - 'client_id' => $this->ssoConfig['admin_client_id'], - 'client_secret' => $this->ssoConfig['admin_client_secret'], - ]; - try { - $response = $this->curl->post($introspectUrl, $body, $headers); - $status = $this->curl->getLastStatusCode(); - $respPreview = is_string($response) ? substr($response, 0, 2000) : print_r($response, true); - if ($status !== 200) { - return null; - } - $decoded = json_decode($response, true); - if (is_array($decoded)) { - return $decoded; - } - return $decoded; - } catch (\Exception $e) { - return null; - } + $answer = json_decode($answer, true); + return $answer; } private function sanitizeUserName(?string $username): ?string { @@ -575,5 +517,4 @@ class SSOService { $username = $this->sanitizeUserName($username); return !(!empty($this->currentUserId) && !empty($this->currentUserName) && $username === $this->currentUserName); } - } -- GitLab From a8f43f12f142e2925c31d333c11aacad8b2160c8 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 12:31:11 +0530 Subject: [PATCH 21/50] updated code by creating function --- lib/Listeners/PasswordUpdatedListener.php | 37 +++++++++++++---------- lib/Service/SSOService.php | 1 - 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 62a0094c..a92b0469 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -44,16 +44,8 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - // Keycloak: logout other online sessions except current one - try { - $accessToken = $this->session->get('oidc_access_token'); - $sid = is_string($accessToken) ? $this->ssoService->getSidFromAccessToken($accessToken) : null; - $this->ssoService->logoutOtherOnlineSessions($username, $sid); - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); - } - - // Nextcloud: logout all sessions except the current one + // Keycloak/Nextcloud: logout other online sessions except current one + $this->handleKeycloakSessions($username); $this->handleNextcloudSessions($username); } @@ -62,14 +54,14 @@ class PasswordUpdatedListener implements IEventListener { */ private function handleNextcloudSessions(string $username): void { try { - $sessionId = $this->session->getId(); - $currentTokenId = $this->tokenProvider->getToken($sessionId)?->getId(); - if ($currentTokenId === null) { + $currentToken = $this->tokenProvider->getToken($this->session->getId()); + if ($currentToken === null) { return; } - $tokens = $this->tokenProvider->getTokenByUser($username); - foreach ($tokens as $token) { - if ($token->getId() === $currentTokenId) { + + $currentId = (string) $currentToken->getId(); + foreach ($this->tokenProvider->getTokenByUser($username) as $token) { + if ((string) $token->getId() === $currentId) { continue; } $this->tokenProvider->invalidateToken((string) $token->getId()); @@ -78,4 +70,17 @@ class PasswordUpdatedListener implements IEventListener { $this->logger->logException($e, ['app' => Application::APP_ID]); } } + + /** + * Logout other Keycloak online sessions except the current one + */ + private function handleKeycloakSessions(string $username): void { + try { + $accessToken = $this->session->get('oidc_access_token'); + $sid = is_string($accessToken) ? $this->ssoService->getSidFromAccessToken($accessToken) : null; + $this->ssoService->logoutOtherOnlineSessions($username, $sid); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } + } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index c9b01e26..cc4aea7d 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -155,7 +155,6 @@ class SSOService { try { $sessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; - $this->logger->debug('logoutOtherOnlineSessions fetching sessions for user: ' . $this->currentUserId); $sessions = $this->callSSOAPI($sessionsUrl, 'GET'); if (!is_array($sessions) || empty($sessions)) { return; -- GitLab From 7b10b5dea94cc984f0e2d1b2206c1e0e1217f73d Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 12:56:13 +0530 Subject: [PATCH 22/50] added api for introspect --- lib/Service/SSOService.php | 55 ++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index cc4aea7d..1b64e311 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -52,6 +52,7 @@ class SSOService { $this->ssoConfig['admin_username'] = $this->config->getSystemValue('oidc_admin_username', ''); $this->ssoConfig['admin_password'] = $this->config->getSystemValue('oidc_admin_password', ''); $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; + $this->ssoConfig['introspect_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token/introspect'; $this->ssoConfig['root_url'] = $rootUrl; $this->crypto = $crypto; $this->curl = $curlService; @@ -130,19 +131,11 @@ class SSOService { if (!is_string($accessToken) || $accessToken === '') { return null; } - $parts = explode('.', $accessToken); - if (count($parts) < 2) { + $introspection = $this->introspectToken($accessToken); + if (!is_array($introspection) || empty($introspection['active'])) { return null; } - $payloadJson = $this->base64UrlDecode($parts[1]); - if ($payloadJson === null) { - return null; - } - $payload = json_decode($payloadJson, true); - if (!is_array($payload)) { - return null; - } - return isset($payload['sid']) && is_string($payload['sid']) ? $payload['sid'] : null; + return isset($introspection['sid']) && is_string($introspection['sid']) ? $introspection['sid'] : null; } /** @@ -175,6 +168,13 @@ class SSOService { $this->logger->warning('Failed to delete session ' . $sessionId . ': ' . $e->getMessage()); } } + + // After removing other online sessions, also revoke all offline sessions (for all clients) + try { + $this->revokeOfflineSessionsForUser(); + } catch (\Exception $e) { + $this->logger->warning('Failed to revoke offline sessions after online session cleanup: ' . $e->getMessage()); + } } catch (\Exception $e) { $this->logger->warning('Failed to fetch or delete sessions for user ' . $this->currentUserId . ': ' . $e->getMessage()); } @@ -189,6 +189,39 @@ class SSOService { return $decoded === false ? null : $decoded; } + /** + * Introspect a user access token using the configured client credentials + */ + public function introspectToken(string $accessToken): ?array { + $introspectUrl = $this->ssoConfig['introspect_url'] ?? ''; + $clientId = $this->ssoConfig['admin_client_id'] ?? ''; + $clientSecret = $this->ssoConfig['admin_client_secret'] ?? ''; + if ($introspectUrl === '' || $clientId === '' || $clientSecret === '' || $accessToken === '') { + return null; + } + + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $body = [ + 'token' => $accessToken, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]; + + try { + $response = $this->curl->post($introspectUrl, $body, $headers); + $statusCode = $this->curl->getLastStatusCode(); + if ($statusCode !== 200) { + $this->logger->warning('Token introspection failed. Status: ' . $statusCode); + return null; + } + $data = json_decode($response, true); + return is_array($data) ? $data : null; + } catch (\Exception $e) { + $this->logger->warning('Token introspection exception: ' . $e->getMessage()); + return null; + } + } + /** * Revoke all offline sessions for the current user in Keycloak -- GitLab From 9180b760f7efcb4745229c39ccecb7583bfed358 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 13:01:47 +0530 Subject: [PATCH 23/50] revoke offline sessions changes --- lib/Listeners/PasswordUpdatedListener.php | 2 ++ lib/Service/SSOService.php | 9 +-------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index a92b0469..683dc3fd 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -79,6 +79,8 @@ class PasswordUpdatedListener implements IEventListener { $accessToken = $this->session->get('oidc_access_token'); $sid = is_string($accessToken) ? $this->ssoService->getSidFromAccessToken($accessToken) : null; $this->ssoService->logoutOtherOnlineSessions($username, $sid); + // Also revoke all offline sessions (for all clients) + $this->ssoService->revokeOfflineSessionsForUser(); } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 1b64e311..1a36f091 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -168,13 +168,6 @@ class SSOService { $this->logger->warning('Failed to delete session ' . $sessionId . ': ' . $e->getMessage()); } } - - // After removing other online sessions, also revoke all offline sessions (for all clients) - try { - $this->revokeOfflineSessionsForUser(); - } catch (\Exception $e) { - $this->logger->warning('Failed to revoke offline sessions after online session cleanup: ' . $e->getMessage()); - } } catch (\Exception $e) { $this->logger->warning('Failed to fetch or delete sessions for user ' . $this->currentUserId . ': ' . $e->getMessage()); } @@ -226,7 +219,7 @@ class SSOService { /** * Revoke all offline sessions for the current user in Keycloak */ - private function revokeOfflineSessionsForUser(): void { + public function revokeOfflineSessionsForUser(): void { try { $clients = $this->getClientsForRealm(); if (empty($clients)) { -- GitLab From 8fbab035e5f36017494f3b275943b06bf109e286 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 25 Sep 2025 13:02:30 +0530 Subject: [PATCH 24/50] removed unncessary function --- lib/Service/SSOService.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 1a36f091..32faa650 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -173,15 +173,6 @@ class SSOService { } } - private function base64UrlDecode(string $input): ?string { - $remainder = strlen($input) % 4; - if ($remainder > 0) { - $input .= str_repeat('=', 4 - $remainder); - } - $decoded = base64_decode(strtr($input, '-_', '+/'), true); - return $decoded === false ? null : $decoded; - } - /** * Introspect a user access token using the configured client credentials */ -- GitLab From 2fadf2d15156599969365cb6e2123c1c86dae1ec Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 26 Sep 2025 14:10:22 +0530 Subject: [PATCH 25/50] moved all logic into service --- lib/Listeners/PasswordUpdatedListener.php | 44 ++-------------------- lib/Service/SSOService.php | 45 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 683dc3fd..af60fd5c 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace OCA\EcloudAccounts\Listeners; -use Exception; -use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Service\SSOService; use OCP\Authentication\Token\IProvider as TokenProvider; use OCP\EventDispatcher\Event; @@ -44,45 +42,9 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - // Keycloak/Nextcloud: logout other online sessions except current one - $this->handleKeycloakSessions($username); - $this->handleNextcloudSessions($username); - } - - /** - * Invalidate all Nextcloud sessions except the current one - */ - private function handleNextcloudSessions(string $username): void { - try { - $currentToken = $this->tokenProvider->getToken($this->session->getId()); - if ($currentToken === null) { - return; - } - - $currentId = (string) $currentToken->getId(); - foreach ($this->tokenProvider->getTokenByUser($username) as $token) { - if ((string) $token->getId() === $currentId) { - continue; - } - $this->tokenProvider->invalidateToken((string) $token->getId()); - } - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); - } + // Keycloak/Nextcloud: delegate cleanup to service + $this->ssoService->logoutSSOSessionsForUser($username, $this->session); + $this->ssoService->logoutNextcloudSessionsForUser($username, $this->session, $this->tokenProvider); } - /** - * Logout other Keycloak online sessions except the current one - */ - private function handleKeycloakSessions(string $username): void { - try { - $accessToken = $this->session->get('oidc_access_token'); - $sid = is_string($accessToken) ? $this->ssoService->getSidFromAccessToken($accessToken) : null; - $this->ssoService->logoutOtherOnlineSessions($username, $sid); - // Also revoke all offline sessions (for all clients) - $this->ssoService->revokeOfflineSessionsForUser(); - } catch (Exception $e) { - $this->logger->logException($e, ['app' => Application::APP_ID]); - } - } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 32faa650..5865e75f 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -7,9 +7,11 @@ use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Db\TwoFactorMapper; use OCA\EcloudAccounts\Exception\SSOAdminAccessTokenException; use OCA\EcloudAccounts\Exception\SSOAdminAPIException; +use OCP\Authentication\Token\IProvider as TokenProvider; use OCP\ICacheFactory; use OCP\IConfig; use OCP\ILogger; +use OCP\ISession; use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Security\ICrypto; @@ -138,6 +140,49 @@ class SSOService { return isset($introspection['sid']) && is_string($introspection['sid']) ? $introspection['sid'] : null; } + /** + * Get current Keycloak session id (sid) by reading the session's OIDC access token and introspecting it + */ + public function getCurrentSessionId(ISession $session): ?string { + $accessToken = $session->get('oidc_access_token'); + return is_string($accessToken) ? $this->getSidFromAccessToken($accessToken) : null; + } + + /** + * Orchestrate SSO cleanup: remove other online sessions, then revoke offline sessions + */ + public function logoutSSOSessionsForUser(string $username, ISession $session): void { + try { + $sid = $this->getCurrentSessionId($session); + $this->logoutOtherOnlineSessions($username, $sid); + $this->revokeOfflineSessionsForUser(); + } catch (\Exception $e) { + $this->logger->warning('Failed to logout SSO sessions for user: ' . $e->getMessage()); + } + } + + /** + * Invalidate all Nextcloud sessions except the current one + */ + public function logoutNextcloudSessionsForUser(string $username, ISession $session, TokenProvider $tokenProvider): void { + try { + $currentToken = $tokenProvider->getToken($session->getId()); + if ($currentToken === null) { + return; + } + + $currentId = (string) $currentToken->getId(); + foreach ($tokenProvider->getTokenByUser($username) as $token) { + if ((string) $token->getId() === $currentId) { + continue; + } + $tokenProvider->invalidateToken((string) $token->getId()); + } + } catch (\Exception $e) { + $this->logger->warning('Failed to logout Nextcloud sessions for user: ' . $e->getMessage()); + } + } + /** * Logout all Keycloak online sessions for the user except the provided current sid */ -- GitLab From 892ef4fb4ed9035769046c47dc0053f4d2e4d56d Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 30 Sep 2025 01:39:19 +0530 Subject: [PATCH 26/50] nextcloud and kc changes --- lib/Listeners/PasswordUpdatedListener.php | 20 +--- lib/Service/SSOService.php | 109 ++++++++++++++-------- 2 files changed, 74 insertions(+), 55 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index af60fd5c..3cf60407 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -8,26 +8,22 @@ use OCA\EcloudAccounts\Service\SSOService; use OCP\Authentication\Token\IProvider as TokenProvider; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\ILogger; +use OCP\IDBConnection; use OCP\ISession; -use OCP\IUserSession; use OCP\User\Events\PasswordUpdatedEvent; class PasswordUpdatedListener implements IEventListener { private SSOService $ssoService; - - private ILogger $logger; private ISession $session; - private IUserSession $userSession; private TokenProvider $tokenProvider; + private IDBConnection $db; - public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession, TokenProvider $tokenProvider) { + public function __construct(SSOService $ssoService, ISession $session, TokenProvider $tokenProvider, IDBConnection $db) { $this->ssoService = $ssoService; - $this->logger = $logger; $this->session = $session; - $this->userSession = $userSession; $this->tokenProvider = $tokenProvider; + $this->db = $db; } public function handle(Event $event): void { @@ -35,16 +31,10 @@ class PasswordUpdatedListener implements IEventListener { return; } - if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { - return; - } - $user = $event->getUser(); $username = $user->getUID(); - - // Keycloak/Nextcloud: delegate cleanup to service + $this->ssoService->logoutNextcloudSessionsViaDb($user, $this->session); $this->ssoService->logoutSSOSessionsForUser($username, $this->session); - $this->ssoService->logoutNextcloudSessionsForUser($username, $this->session, $this->tokenProvider); } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 5865e75f..6404a2f5 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -7,11 +7,12 @@ use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Db\TwoFactorMapper; use OCA\EcloudAccounts\Exception\SSOAdminAccessTokenException; use OCA\EcloudAccounts\Exception\SSOAdminAPIException; -use OCP\Authentication\Token\IProvider as TokenProvider; use OCP\ICacheFactory; use OCP\IConfig; +use OCP\IDBConnection; use OCP\ILogger; use OCP\ISession; +use OCP\IUser; use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Security\ICrypto; @@ -31,6 +32,7 @@ class SSOService { private IUserManager $userManager; private TwoFactorMapper $twoFactorMapper; private ICacheFactory $cacheFactory; + private IDBConnection $db; private string $mainDomain; private string $legacyDomain; @@ -40,7 +42,7 @@ class SSOService { private const CREDENTIALS_ENDPOINT = '/users/{USER_ID}/credentials'; private const CLIENTS_CACHE_TTL = 6 * 3600; // 6 hours - public function __construct($appName, IConfig $config, CurlService $curlService, ICrypto $crypto, IFactory $l10nFactory, IUserManager $userManager, TwoFactorMapper $twoFactorMapper, ILogger $logger, ICacheFactory $cacheFactory) { + public function __construct($appName, IConfig $config, CurlService $curlService, ICrypto $crypto, IFactory $l10nFactory, IUserManager $userManager, TwoFactorMapper $twoFactorMapper, ILogger $logger, ICacheFactory $cacheFactory, IDBConnection $db) { $this->appName = $appName; $this->config = $config; @@ -63,6 +65,7 @@ class SSOService { $this->userManager = $userManager; $this->twoFactorMapper = $twoFactorMapper; $this->cacheFactory = $cacheFactory; + $this->db = $db; $this->mainDomain = $this->config->getSystemValue("main_domain"); $this->legacyDomain = $this->config->getSystemValue("legacy_domain"); @@ -88,7 +91,7 @@ class SSOService { 'credentials' => [$credentialEntry] ]; - $this->logger->debug('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); + $this->logger->error('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); $this->callSSOAPI($url, 'PUT', $data, 204); } @@ -103,7 +106,7 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); $url .= '/' . $credentialId; - $this->logger->debug('deleteCredentials calling SSO API with url: '. $url); + $this->logger->error('deleteCredentials calling SSO API with url: '. $url); $this->callSSOAPI($url, 'DELETE', [], 204); } } @@ -115,7 +118,7 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; - $this->logger->debug('logout calling SSO API with url: '. $url); + $this->logger->error('logout calling SSO API with url: '. $url); $this->callSSOAPI($url, 'POST', [], 204); // Revoke offline sessions for all clients @@ -154,39 +157,18 @@ class SSOService { public function logoutSSOSessionsForUser(string $username, ISession $session): void { try { $sid = $this->getCurrentSessionId($session); - $this->logoutOtherOnlineSessions($username, $sid); - $this->revokeOfflineSessionsForUser(); + $this->logoutOtherOnlineSessions($username, $sid, true); } catch (\Exception $e) { $this->logger->warning('Failed to logout SSO sessions for user: ' . $e->getMessage()); } } /** - * Invalidate all Nextcloud sessions except the current one + * Logout Keycloak online sessions for the user. + * If $preserveCurrent is true, keep the session matching $currentSid (or most recent if $currentSid is null/empty). + * If $preserveCurrent is false, remove all sessions. */ - public function logoutNextcloudSessionsForUser(string $username, ISession $session, TokenProvider $tokenProvider): void { - try { - $currentToken = $tokenProvider->getToken($session->getId()); - if ($currentToken === null) { - return; - } - - $currentId = (string) $currentToken->getId(); - foreach ($tokenProvider->getTokenByUser($username) as $token) { - if ((string) $token->getId() === $currentId) { - continue; - } - $tokenProvider->invalidateToken((string) $token->getId()); - } - } catch (\Exception $e) { - $this->logger->warning('Failed to logout Nextcloud sessions for user: ' . $e->getMessage()); - } - } - - /** - * Logout all Keycloak online sessions for the user except the provided current sid - */ - public function logoutOtherOnlineSessions(string $username, ?string $currentSid): void { + public function logoutOtherOnlineSessions(string $username, ?string $currentSid, bool $preserveCurrent = true): void { if($this->isNotCurrentUser($username)) { $this->setupUserId($username); } @@ -198,12 +180,25 @@ class SSOService { return; } + $keepSid = null; + if ($preserveCurrent) { + $keepSid = $currentSid; + if ($keepSid === null || $keepSid === '') { + $keepSid = $this->pickMostRecentSessionId($sessions); + $this->logger->error('Keycloak: no current sid; keeping most recent sid=' . ($keepSid ?? 'none')); + } else { + $this->logger->error('Keycloak: keeping provided sid=' . $keepSid); + } + } else { + $this->logger->error('Keycloak: preserveCurrent=false; removing all sessions'); + } + foreach ($sessions as $session) { if (!isset($session['id'])) { continue; } $sessionId = (string)$session['id']; - if ($currentSid !== null && $sessionId === $currentSid) { + if ($keepSid !== null && $sessionId === $keepSid) { continue; // preserve current session } $deleteUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; @@ -218,6 +213,25 @@ class SSOService { } } + /** + * Pick the most recently active session ID from a list of sessions + */ + private function pickMostRecentSessionId(array $sessions): ?string { + $keepSessionId = null; + $maxLastAccess = -1; + foreach ($sessions as $s) { + if (!isset($s['id'])) { + continue; + } + $lastAccess = isset($s['lastAccess']) ? intval($s['lastAccess']) : 0; + if ($lastAccess > $maxLastAccess) { + $maxLastAccess = $lastAccess; + $keepSessionId = $s['id']; + } + } + return $keepSessionId; + } + /** * Introspect a user access token using the configured client credentials */ @@ -255,7 +269,7 @@ class SSOService { /** * Revoke all offline sessions for the current user in Keycloak */ - public function revokeOfflineSessionsForUser(): void { + public function revokeOfflineSessionsForUser(?string $keepSid = null): void { try { $clients = $this->getClientsForRealm(); if (empty($clients)) { @@ -264,7 +278,7 @@ class SSOService { foreach ($clients as $client) { try { - $this->revokeOfflineSessionsForClient($client); + $this->revokeOfflineSessionsForClient($client, $keepSid); } catch (\Exception $e) { $this->logger->warning('Failed to revoke offline sessions for client: ' . (isset($client['id']) ? $client['id'] : 'unknown') . ' - ' . $e->getMessage()); // Continue with other clients even if one fails @@ -287,7 +301,7 @@ class SSOService { $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); $cachedClients = $clientsCache->get($cacheKey); if ($cachedClients !== null) { - $this->logger->debug('Returning cached clients from distributed cache'); + $this->logger->error('Returning cached clients from distributed cache'); return $cachedClients; } @@ -311,7 +325,7 @@ class SSOService { /** * Revoke offline sessions for a specific client */ - private function revokeOfflineSessionsForClient(array $client): void { + private function revokeOfflineSessionsForClient(array $client, ?string $keepSid = null): void { if (!isset($client['id'])) { return; } @@ -321,12 +335,15 @@ class SSOService { // Get offline sessions for this client and user $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); if (empty($offlineSessions)) { - $this->logger->debug('No offline sessions found for client: ' . $clientUUID); + $this->logger->error('No offline sessions found for client: ' . $clientUUID); return; } // Delete each offline session individually foreach ($offlineSessions as $session) { + if (isset($session['id']) && $keepSid !== null && $session['id'] === $keepSid) { + continue; // preserve current offline session + } $this->deleteOfflineSession($session); } } @@ -384,7 +401,7 @@ class SSOService { private function getCredentialIds() : array { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); - $this->logger->debug('getCredentialIds calling SSO API with url: '. $url); + $this->logger->error('getCredentialIds calling SSO API with url: '. $url); $credentials = $this->callSSOAPI($url, 'GET'); @@ -454,7 +471,7 @@ class SSOService { } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&email=' . $email; - $this->logger->debug('getUserId calling SSO API with url: '. $url); + $this->logger->error('getUserId calling SSO API with url: '. $url); $users = $this->callSSOAPI($url, 'GET'); if (empty($users) || !is_array($users) || !isset($users[0])) { throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); @@ -506,7 +523,7 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; - $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); + $this->logger->error('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); if ($this->curl->getLastStatusCode() !== 200) { @@ -578,4 +595,16 @@ class SSOService { $username = $this->sanitizeUserName($username); return !(!empty($this->currentUserId) && !empty($this->currentUserName) && $username === $this->currentUserName); } + + /** + * Remove all Nextcloud auth tokens (authtoken) for a user except the current session id + */ + public function logoutNextcloudSessionsViaDb(IUser $user, ISession $session): void { + $currentSessionId = $session->getId(); + $qb = $this->db->getQueryBuilder(); + $qb->delete('authtoken') + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($user->getUID()))) + ->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($currentSessionId))); + $qb->executeStatement(); + } } -- GitLab From b2f20f63eae1d0069428133b191ff85489619fc7 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 30 Sep 2025 03:10:47 +0530 Subject: [PATCH 27/50] changes --- lib/Listeners/PasswordUpdatedListener.php | 10 ++++- lib/Service/SSOService.php | 49 ++++++++++------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 3cf60407..203c20d1 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -33,7 +33,15 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - $this->ssoService->logoutNextcloudSessionsViaDb($user, $this->session); + $keepTokenId = null; + try { + $sessionTokenId = $this->session->get('token'); + if (is_string($sessionTokenId) && $sessionTokenId !== '') { + $keepTokenId = $sessionTokenId; + } + } catch (\Throwable $e) { + } + $this->ssoService->logoutNextcloudSessionsViaDb($user, $keepTokenId); $this->ssoService->logoutSSOSessionsForUser($username, $this->session); } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 6404a2f5..89fe8cd5 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -55,6 +55,8 @@ class SSOService { $this->ssoConfig['admin_client_secret'] = $this->config->getSystemValue('oidc_admin_client_secret', ''); $this->ssoConfig['admin_username'] = $this->config->getSystemValue('oidc_admin_username', ''); $this->ssoConfig['admin_password'] = $this->config->getSystemValue('oidc_admin_password', ''); + $this->ssoConfig['login_client_id'] = $this->config->getSystemValue('oidc_login_client_id', ''); + $this->ssoConfig['login_client_secret'] = $this->config->getSystemValue('oidc_login_client_secret', ''); $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; $this->ssoConfig['introspect_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token/introspect'; $this->ssoConfig['root_url'] = $rootUrl; @@ -91,7 +93,6 @@ class SSOService { 'credentials' => [$credentialEntry] ]; - $this->logger->error('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); $this->callSSOAPI($url, 'PUT', $data, 204); } @@ -106,7 +107,6 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); $url .= '/' . $credentialId; - $this->logger->error('deleteCredentials calling SSO API with url: '. $url); $this->callSSOAPI($url, 'DELETE', [], 204); } } @@ -118,14 +118,13 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; - $this->logger->error('logout calling SSO API with url: '. $url); $this->callSSOAPI($url, 'POST', [], 204); // Revoke offline sessions for all clients try { $this->revokeOfflineSessionsForUser(); } catch (\Exception $e) { - $this->logger->warning('Failed to revoke offline sessions: ' . $e->getMessage()); + $this->logger->error('Failed to revoke offline sessions: ' . $e->getMessage()); } } @@ -148,6 +147,7 @@ class SSOService { */ public function getCurrentSessionId(ISession $session): ?string { $accessToken = $session->get('oidc_access_token'); + return is_string($accessToken) ? $this->getSidFromAccessToken($accessToken) : null; } @@ -158,8 +158,10 @@ class SSOService { try { $sid = $this->getCurrentSessionId($session); $this->logoutOtherOnlineSessions($username, $sid, true); + // Also remove all offline sessions (no preserve) + $this->revokeOfflineSessionsForUser(null); } catch (\Exception $e) { - $this->logger->warning('Failed to logout SSO sessions for user: ' . $e->getMessage()); + $this->logger->error('Failed to logout SSO sessions for user: ' . $e->getMessage()); } } @@ -185,12 +187,7 @@ class SSOService { $keepSid = $currentSid; if ($keepSid === null || $keepSid === '') { $keepSid = $this->pickMostRecentSessionId($sessions); - $this->logger->error('Keycloak: no current sid; keeping most recent sid=' . ($keepSid ?? 'none')); - } else { - $this->logger->error('Keycloak: keeping provided sid=' . $keepSid); } - } else { - $this->logger->error('Keycloak: preserveCurrent=false; removing all sessions'); } foreach ($sessions as $session) { @@ -205,11 +202,11 @@ class SSOService { try { $this->callSSOAPI($deleteUrl, 'DELETE', [], 204); } catch (\Exception $e) { - $this->logger->warning('Failed to delete session ' . $sessionId . ': ' . $e->getMessage()); + $this->logger->error('Failed to delete session ' . $sessionId . ': ' . $e->getMessage()); } } } catch (\Exception $e) { - $this->logger->warning('Failed to fetch or delete sessions for user ' . $this->currentUserId . ': ' . $e->getMessage()); + $this->logger->error('Failed to fetch or delete sessions for user ' . $this->currentUserId . ': ' . $e->getMessage()); } } @@ -237,8 +234,8 @@ class SSOService { */ public function introspectToken(string $accessToken): ?array { $introspectUrl = $this->ssoConfig['introspect_url'] ?? ''; - $clientId = $this->ssoConfig['admin_client_id'] ?? ''; - $clientSecret = $this->ssoConfig['admin_client_secret'] ?? ''; + $clientId = $this->ssoConfig['login_client_id'] ?? ''; + $clientSecret = $this->ssoConfig['login_client_secret'] ?? ''; if ($introspectUrl === '' || $clientId === '' || $clientSecret === '' || $accessToken === '') { return null; } @@ -254,13 +251,13 @@ class SSOService { $response = $this->curl->post($introspectUrl, $body, $headers); $statusCode = $this->curl->getLastStatusCode(); if ($statusCode !== 200) { - $this->logger->warning('Token introspection failed. Status: ' . $statusCode); + $this->logger->error('Token introspection failed. Status: ' . $statusCode); return null; } $data = json_decode($response, true); return is_array($data) ? $data : null; } catch (\Exception $e) { - $this->logger->warning('Token introspection exception: ' . $e->getMessage()); + $this->logger->error('Token introspection exception: ' . $e->getMessage()); return null; } } @@ -280,12 +277,12 @@ class SSOService { try { $this->revokeOfflineSessionsForClient($client, $keepSid); } catch (\Exception $e) { - $this->logger->warning('Failed to revoke offline sessions for client: ' . (isset($client['id']) ? $client['id'] : 'unknown') . ' - ' . $e->getMessage()); + $this->logger->error('Failed to revoke offline sessions for client: ' . (isset($client['id']) ? $client['id'] : 'unknown') . ' - ' . $e->getMessage()); // Continue with other clients even if one fails } } } catch (\Exception $e) { - $this->logger->warning('Failed to get clients for offline session revocation: ' . $e->getMessage()); + $this->logger->error('Failed to get clients for offline session revocation: ' . $e->getMessage()); } } @@ -301,7 +298,6 @@ class SSOService { $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); $cachedClients = $clientsCache->get($cacheKey); if ($cachedClients !== null) { - $this->logger->error('Returning cached clients from distributed cache'); return $cachedClients; } @@ -309,7 +305,6 @@ class SSOService { $clients = $this->callSSOAPI($clientsUrl, 'GET'); if (!is_array($clients)) { - $this->logger->error('Could not fetch clients for offline session revocation.'); return []; } @@ -401,7 +396,6 @@ class SSOService { private function getCredentialIds() : array { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); - $this->logger->error('getCredentialIds calling SSO API with url: '. $url); $credentials = $this->callSSOAPI($url, 'GET'); @@ -471,7 +465,6 @@ class SSOService { } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&email=' . $email; - $this->logger->error('getUserId calling SSO API with url: '. $url); $users = $this->callSSOAPI($url, 'GET'); if (empty($users) || !is_array($users) || !isset($users[0])) { throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); @@ -523,7 +516,6 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; - $this->logger->error('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); if ($this->curl->getLastStatusCode() !== 200) { @@ -597,14 +589,15 @@ class SSOService { } /** - * Remove all Nextcloud auth tokens (authtoken) for a user except the current session id + * Remove all Nextcloud auth tokens (authtoken) for a user except the provided current token id */ - public function logoutNextcloudSessionsViaDb(IUser $user, ISession $session): void { - $currentSessionId = $session->getId(); + public function logoutNextcloudSessionsViaDb(IUser $user, ?string $keepTokenId): void { $qb = $this->db->getQueryBuilder(); $qb->delete('authtoken') - ->where($qb->expr()->eq('uid', $qb->createNamedParameter($user->getUID()))) - ->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($currentSessionId))); + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($user->getUID()))); + if ($keepTokenId !== null && $keepTokenId !== '') { + $qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($keepTokenId))); + } $qb->executeStatement(); } } -- GitLab From 341fd1e2de06a403d7938be21cf9ced2de56e4d6 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 30 Sep 2025 03:12:10 +0530 Subject: [PATCH 28/50] changes --- lib/Service/SSOService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 89fe8cd5..678a6ec2 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -124,7 +124,7 @@ class SSOService { try { $this->revokeOfflineSessionsForUser(); } catch (\Exception $e) { - $this->logger->error('Failed to revoke offline sessions: ' . $e->getMessage()); + $this->logger->warning('Failed to revoke offline sessions: ' . $e->getMessage()); } } -- GitLab From d7443022cf233cb51ec89aca537e8c2fd5f280e3 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 30 Sep 2025 03:14:35 +0530 Subject: [PATCH 29/50] changes --- lib/Service/SSOService.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 678a6ec2..f71c6e34 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -158,8 +158,8 @@ class SSOService { try { $sid = $this->getCurrentSessionId($session); $this->logoutOtherOnlineSessions($username, $sid, true); - // Also remove all offline sessions (no preserve) - $this->revokeOfflineSessionsForUser(null); + // Also remove all offline sessions + $this->revokeOfflineSessionsForUser(); } catch (\Exception $e) { $this->logger->error('Failed to logout SSO sessions for user: ' . $e->getMessage()); } @@ -266,7 +266,7 @@ class SSOService { /** * Revoke all offline sessions for the current user in Keycloak */ - public function revokeOfflineSessionsForUser(?string $keepSid = null): void { + public function revokeOfflineSessionsForUser(): void { try { $clients = $this->getClientsForRealm(); if (empty($clients)) { @@ -275,7 +275,7 @@ class SSOService { foreach ($clients as $client) { try { - $this->revokeOfflineSessionsForClient($client, $keepSid); + $this->revokeOfflineSessionsForClient($client); } catch (\Exception $e) { $this->logger->error('Failed to revoke offline sessions for client: ' . (isset($client['id']) ? $client['id'] : 'unknown') . ' - ' . $e->getMessage()); // Continue with other clients even if one fails @@ -320,7 +320,7 @@ class SSOService { /** * Revoke offline sessions for a specific client */ - private function revokeOfflineSessionsForClient(array $client, ?string $keepSid = null): void { + private function revokeOfflineSessionsForClient(array $client): void { if (!isset($client['id'])) { return; } @@ -336,9 +336,6 @@ class SSOService { // Delete each offline session individually foreach ($offlineSessions as $session) { - if (isset($session['id']) && $keepSid !== null && $session['id'] === $keepSid) { - continue; // preserve current offline session - } $this->deleteOfflineSession($session); } } -- GitLab From a1d5457d64bc75c3f62a0d8788bc58a16462b18d Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 30 Sep 2025 03:17:02 +0530 Subject: [PATCH 30/50] changes --- lib/Service/SSOService.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index f71c6e34..2a475af1 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -184,10 +184,14 @@ class SSOService { $keepSid = null; if ($preserveCurrent) { - $keepSid = $currentSid; - if ($keepSid === null || $keepSid === '') { - $keepSid = $this->pickMostRecentSessionId($sessions); + if ($currentSid !== null && $currentSid !== '') { + $keepSid = $currentSid; + $this->logger->error('Keycloak: keeping provided sid=' . $keepSid); + } else { + $this->logger->error('Keycloak: no sid provided; removing all online sessions'); } + } else { + $this->logger->error('Keycloak: preserveCurrent=false; removing all sessions'); } foreach ($sessions as $session) { -- GitLab From eb7f5397b538e3316dd001db4f3fd4f80f17e577 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 30 Sep 2025 11:27:56 +0530 Subject: [PATCH 31/50] debugger reverted --- lib/Service/SSOService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 2a475af1..4e43e64c 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -334,7 +334,7 @@ class SSOService { // Get offline sessions for this client and user $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); if (empty($offlineSessions)) { - $this->logger->error('No offline sessions found for client: ' . $clientUUID); + $this->logger->debug('No offline sessions found for client: ' . $clientUUID); return; } -- GitLab From 3dd53e936b7fea92fb696954abe8e6f06edea1b0 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 3 Oct 2025 11:40:05 +0530 Subject: [PATCH 32/50] remvove all sessiong --- lib/Listeners/PasswordUpdatedListener.php | 23 +- lib/Service/SSOService.php | 278 ++++++---------------- 2 files changed, 83 insertions(+), 218 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 203c20d1..575c4a22 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -33,16 +33,25 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - $keepTokenId = null; + // 1) Logout ALL SSO sessions (bulk; fallback to per-session deletion) + $this->ssoService->logoutAllSSOSessionsForUser($username); + + // 2) Set Not-Before so introspected tokens become inactive immediately + try { + $this->ssoService->setUserNotBeforeNow($username); + } catch (\Throwable $e) { + } + + // 3) Cleanup ALL Nextcloud tokens (do not preserve current) + $this->ssoService->logoutNextcloudSessionsViaDb($user, null); + // Also revoke all consents to invalidate any offline tokens (e.g., eOS clients) try { - $sessionTokenId = $this->session->get('token'); - if (is_string($sessionTokenId) && $sessionTokenId !== '') { - $keepTokenId = $sessionTokenId; - } + $this->ssoService->revokeAllConsentsForUser($username); } catch (\Throwable $e) { + // Best-effort; continue even if this fails } - $this->ssoService->logoutNextcloudSessionsViaDb($user, $keepTokenId); - $this->ssoService->logoutSSOSessionsForUser($username, $this->session); + + // Not-before already set at the beginning } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 4e43e64c..a27d0b59 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -11,7 +11,6 @@ use OCP\ICacheFactory; use OCP\IConfig; use OCP\IDBConnection; use OCP\ILogger; -use OCP\ISession; use OCP\IUser; use OCP\IUserManager; use OCP\L10N\IFactory; @@ -111,276 +110,133 @@ class SSOService { } } - public function logout(string $username) : void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; - - $this->callSSOAPI($url, 'POST', [], 204); - - // Revoke offline sessions for all clients - try { - $this->revokeOfflineSessionsForUser(); - } catch (\Exception $e) { - $this->logger->warning('Failed to revoke offline sessions: ' . $e->getMessage()); - } - } + // Removed: legacy logout method; use logoutAllSSOSessionsForUser instead /** - * Extract Keycloak session id (sid) from a JWT access token without remote introspection + * Logout ALL Keycloak online sessions for the user. Fallback to per-session deletion if bulk logout fails */ - public function getSidFromAccessToken(string $accessToken): ?string { - if (!is_string($accessToken) || $accessToken === '') { - return null; - } - $introspection = $this->introspectToken($accessToken); - if (!is_array($introspection) || empty($introspection['active'])) { - return null; + public function logoutAllSSOSessionsForUser(string $username): void { + if ($this->isNotCurrentUser($username)) { + $this->setupUserId($username); } - return isset($introspection['sid']) && is_string($introspection['sid']) ? $introspection['sid'] : null; - } - - /** - * Get current Keycloak session id (sid) by reading the session's OIDC access token and introspecting it - */ - public function getCurrentSessionId(ISession $session): ?string { - $accessToken = $session->get('oidc_access_token'); - return is_string($accessToken) ? $this->getSidFromAccessToken($accessToken) : null; - } - - /** - * Orchestrate SSO cleanup: remove other online sessions, then revoke offline sessions - */ - public function logoutSSOSessionsForUser(string $username, ISession $session): void { + // Try bulk user logout first try { - $sid = $this->getCurrentSessionId($session); - $this->logoutOtherOnlineSessions($username, $sid, true); - // Also remove all offline sessions - $this->revokeOfflineSessionsForUser(); + $logoutUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; + $this->callSSOAPI($logoutUrl, 'POST', [], 204); + $this->logger->error('====> Performed bulk Keycloak logout for userId=' . $this->currentUserId); + return; } catch (\Exception $e) { - $this->logger->error('Failed to logout SSO sessions for user: ' . $e->getMessage()); - } - } - - /** - * Logout Keycloak online sessions for the user. - * If $preserveCurrent is true, keep the session matching $currentSid (or most recent if $currentSid is null/empty). - * If $preserveCurrent is false, remove all sessions. - */ - public function logoutOtherOnlineSessions(string $username, ?string $currentSid, bool $preserveCurrent = true): void { - if($this->isNotCurrentUser($username)) { - $this->setupUserId($username); + $this->logger->error('Bulk Keycloak logout failed for userId=' . $this->currentUserId . ': ' . $e->getMessage() . ' - falling back to per-session deletion'); } + // Fallback: list sessions then delete individually try { $sessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; $sessions = $this->callSSOAPI($sessionsUrl, 'GET'); if (!is_array($sessions) || empty($sessions)) { + $this->logger->error('====> No online sessions found to delete for userId=' . $this->currentUserId); return; } - - $keepSid = null; - if ($preserveCurrent) { - if ($currentSid !== null && $currentSid !== '') { - $keepSid = $currentSid; - $this->logger->error('Keycloak: keeping provided sid=' . $keepSid); - } else { - $this->logger->error('Keycloak: no sid provided; removing all online sessions'); - } - } else { - $this->logger->error('Keycloak: preserveCurrent=false; removing all sessions'); - } - foreach ($sessions as $session) { if (!isset($session['id'])) { continue; } $sessionId = (string)$session['id']; - if ($keepSid !== null && $sessionId === $keepSid) { - continue; // preserve current session - } $deleteUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; try { + $this->logger->error('====> Deleting online session ' . $sessionId . ' for userId=' . $this->currentUserId); $this->callSSOAPI($deleteUrl, 'DELETE', [], 204); } catch (\Exception $e) { - $this->logger->error('Failed to delete session ' . $sessionId . ': ' . $e->getMessage()); + $this->logger->error('Failed to delete online session ' . $sessionId . ': ' . $e->getMessage()); } } } catch (\Exception $e) { - $this->logger->error('Failed to fetch or delete sessions for user ' . $this->currentUserId . ': ' . $e->getMessage()); + $this->logger->error('Failed to fetch sessions for userId=' . $this->currentUserId . ': ' . $e->getMessage()); } } - /** - * Pick the most recently active session ID from a list of sessions - */ - private function pickMostRecentSessionId(array $sessions): ?string { - $keepSessionId = null; - $maxLastAccess = -1; - foreach ($sessions as $s) { - if (!isset($s['id'])) { - continue; - } - $lastAccess = isset($s['lastAccess']) ? intval($s['lastAccess']) : 0; - if ($lastAccess > $maxLastAccess) { - $maxLastAccess = $lastAccess; - $keepSessionId = $s['id']; - } - } - return $keepSessionId; - } + // Removed: sid extraction and current session helpers; not needed with immediate invalidation - /** - * Introspect a user access token using the configured client credentials - */ - public function introspectToken(string $accessToken): ?array { - $introspectUrl = $this->ssoConfig['introspect_url'] ?? ''; - $clientId = $this->ssoConfig['login_client_id'] ?? ''; - $clientSecret = $this->ssoConfig['login_client_secret'] ?? ''; - if ($introspectUrl === '' || $clientId === '' || $clientSecret === '' || $accessToken === '') { - return null; - } + // Removed: legacy orchestrator kept for reference; use logoutAllSSOSessionsForUser + revokeOfflineSessionsForUser - $headers = ['Content-Type: application/x-www-form-urlencoded']; - $body = [ - 'token' => $accessToken, - 'client_id' => $clientId, - 'client_secret' => $clientSecret, - ]; + // Removed: selective session logout; using logoutAllSSOSessionsForUser for immediate invalidation - try { - $response = $this->curl->post($introspectUrl, $body, $headers); - $statusCode = $this->curl->getLastStatusCode(); - if ($statusCode !== 200) { - $this->logger->error('Token introspection failed. Status: ' . $statusCode); - return null; - } - $data = json_decode($response, true); - return is_array($data) ? $data : null; - } catch (\Exception $e) { - $this->logger->error('Token introspection exception: ' . $e->getMessage()); - return null; - } - } + // Removed: pickMostRecentSessionId; not used in immediate invalidation flow + // Removed: introspectToken; not used by current flow + + + // Removed: offline session revocation orchestration (using consent deletion instead) /** - * Revoke all offline sessions for the current user in Keycloak + * Revoke all user consents to invalidate offline tokens for all clients */ - public function revokeOfflineSessionsForUser(): void { + public function revokeAllConsentsForUser(string $username): void { + if ($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } + try { - $clients = $this->getClientsForRealm(); - if (empty($clients)) { + $consentsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents'; + $consents = $this->callSSOAPI($consentsUrl, 'GET'); + if (!is_array($consents) || empty($consents)) { + $this->logger->error('====> No user consents found (userId=' . $this->currentUserId . ')'); return; } + $this->logger->error('====> Consents fetched (count=' . count($consents) . ') for userId=' . $this->currentUserId . ': ' . json_encode($consents)); - foreach ($clients as $client) { + $deletedClients = []; + foreach ($consents as $consent) { + // Keycloak returns OAuthClientConsentRepresentation with 'clientId' + if (!isset($consent['clientId']) || !is_string($consent['clientId']) || $consent['clientId'] === '') { + continue; + } + $clientId = $consent['clientId']; + $this->logger->error('====> Revoking user consent for client ' . $clientId . ' (userId=' . $this->currentUserId . ')'); + $deleteConsentUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents/' . rawurlencode($clientId); try { - $this->revokeOfflineSessionsForClient($client); + $this->callSSOAPI($deleteConsentUrl, 'DELETE', [], 204); + $this->logger->error('====> Deleted consent for client ' . $clientId . ' (userId=' . $this->currentUserId . ')'); + $deletedClients[] = $clientId; } catch (\Exception $e) { - $this->logger->error('Failed to revoke offline sessions for client: ' . (isset($client['id']) ? $client['id'] : 'unknown') . ' - ' . $e->getMessage()); - // Continue with other clients even if one fails + $this->logger->error('Failed to delete consent for client ' . $clientId . ': ' . $e->getMessage()); } } + $this->logger->error('====> Revoked consents summary (userId=' . $this->currentUserId . '): ' . json_encode($deletedClients)); } catch (\Exception $e) { - $this->logger->error('Failed to get clients for offline session revocation: ' . $e->getMessage()); + $this->logger->error('Failed to fetch user consents for user ' . $this->currentUserId . ': ' . $e->getMessage()); } } /** - * Get all clients for the current realm with caching + * Set Keycloak user Not-Before to now to invalidate all tokens issued before now */ - private function getClientsForRealm(): array { - // Create unique cache key based on URL (no parameters for this endpoint) - $clientsUrl = $this->ssoConfig['admin_rest_api_url'] . '/clients'; - $cacheKey = "{$clientsUrl}|no_params|no_keys"; - - // Try to get cached clients - $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); - $cachedClients = $clientsCache->get($cacheKey); - if ($cachedClients !== null) { - return $cachedClients; + public function setUserNotBeforeNow(string $username): void { + if ($this->isNotCurrentUser($username)) { + $this->setupUserId($username); } - - try { - $clients = $this->callSSOAPI($clientsUrl, 'GET'); - - if (!is_array($clients)) { - return []; - } - $clientsCache->set($cacheKey, $clients, self::CLIENTS_CACHE_TTL); - - return $clients; + $now = time(); + // Use documented endpoint: PUT /admin/realms/{realm}/users/{id} with notBefore field + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; + $payload = ['notBefore' => $now]; + try { + $this->logger->error('====> Setting user not-before to ' . $now . ' (userId=' . $this->currentUserId . ')'); + $this->callSSOAPI($url, 'PUT', $payload, 204); + $this->logger->error('====> Set user not-before done (userId=' . $this->currentUserId . ')'); } catch (\Exception $e) { - $this->logger->error('Failed to fetch clients for offline session revocation: ' . $e->getMessage()); - return []; - } - } - - /** - * Revoke offline sessions for a specific client - */ - private function revokeOfflineSessionsForClient(array $client): void { - if (!isset($client['id'])) { - return; - } - - $clientUUID = $client['id']; - - // Get offline sessions for this client and user - $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); - if (empty($offlineSessions)) { - $this->logger->debug('No offline sessions found for client: ' . $clientUUID); - return; - } - - // Delete each offline session individually - foreach ($offlineSessions as $session) { - $this->deleteOfflineSession($session); + $this->logger->error('Failed to set user not-before for userId=' . $this->currentUserId . ': ' . $e->getMessage()); } } - /** - * Get offline sessions for a specific client and user - */ - private function getOfflineSessionsForClient(string $clientUUID): array { - $offlineSessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/offline-sessions/' . $clientUUID . '?isOffline=true'; - - try { - $offlineSessions = $this->callSSOAPI($offlineSessionsUrl, 'GET'); - - if (!is_array($offlineSessions)) { - return []; - } + // Removed: getClientsForRealm; not needed after consent-based approach - return $offlineSessions; - } catch (\Exception $e) { - $this->logger->error('Failed to fetch offline sessions for client ' . $clientUUID . ': ' . $e->getMessage()); - return []; - } - } + // Removed: revokeOfflineSessionsForClient; not used - /** - * Delete a specific offline session - */ - private function deleteOfflineSession(array $session): void { - if (!isset($session['id'])) { - return; - } + // Removed: getOfflineSessionsForClient; not used - $sessionId = $session['id']; - $deleteSessionUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId . '?isOffline=true'; - - try { - $this->callSSOAPI($deleteSessionUrl, 'DELETE', [], 204); - } catch (\Exception $e) { - $this->logger->error('Failed to delete offline session ' . $sessionId . ': ' . $e->getMessage()); - } - } + // Removed: deleteOfflineSession; not used public function handle2FAStateChange(bool $enabled, string $username) : void { // When state change event is fired by user disabling 2FA, delete existing 2FA credentials and return -- GitLab From 1c6b852ab1430f540a7d61fd4d94514c4b4b5ce9 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 3 Oct 2025 12:39:59 +0530 Subject: [PATCH 33/50] logout all kc and nc sessions --- lib/Listeners/PasswordUpdatedListener.php | 18 ++---------------- lib/Service/SSOService.php | 14 -------------- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 575c4a22..b346e616 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -33,25 +33,11 @@ class PasswordUpdatedListener implements IEventListener { $user = $event->getUser(); $username = $user->getUID(); - // 1) Logout ALL SSO sessions (bulk; fallback to per-session deletion) + // Logout ALL Keycloak sessions $this->ssoService->logoutAllSSOSessionsForUser($username); - - // 2) Set Not-Before so introspected tokens become inactive immediately - try { - $this->ssoService->setUserNotBeforeNow($username); - } catch (\Throwable $e) { - } - - // 3) Cleanup ALL Nextcloud tokens (do not preserve current) + // Cleanup ALL Nextcloud tokens $this->ssoService->logoutNextcloudSessionsViaDb($user, null); - // Also revoke all consents to invalidate any offline tokens (e.g., eOS clients) - try { - $this->ssoService->revokeAllConsentsForUser($username); - } catch (\Throwable $e) { - // Best-effort; continue even if this fails - } - // Not-before already set at the beginning } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index a27d0b59..16658942 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -110,8 +110,6 @@ class SSOService { } } - // Removed: legacy logout method; use logoutAllSSOSessionsForUser instead - /** * Logout ALL Keycloak online sessions for the user. Fallback to per-session deletion if bulk logout fails */ @@ -156,18 +154,6 @@ class SSOService { } } - // Removed: sid extraction and current session helpers; not needed with immediate invalidation - - // Removed: legacy orchestrator kept for reference; use logoutAllSSOSessionsForUser + revokeOfflineSessionsForUser - - // Removed: selective session logout; using logoutAllSSOSessionsForUser for immediate invalidation - - // Removed: pickMostRecentSessionId; not used in immediate invalidation flow - - // Removed: introspectToken; not used by current flow - - - // Removed: offline session revocation orchestration (using consent deletion instead) /** * Revoke all user consents to invalidate offline tokens for all clients -- GitLab From f193b9509e09d63ce83ef278bc6982aa89bcbd95 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 3 Oct 2025 13:38:26 +0530 Subject: [PATCH 34/50] removed consents --- lib/Listeners/PasswordUpdatedListener.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index b346e616..a9969dec 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -38,6 +38,12 @@ class PasswordUpdatedListener implements IEventListener { // Cleanup ALL Nextcloud tokens $this->ssoService->logoutNextcloudSessionsViaDb($user, null); + // Revoke all consents to invalidate offline tokens (prevents eOS from restoring session) + try { + $this->ssoService->revokeAllConsentsForUser($username); + } catch (\Throwable $e) { + } + } } -- GitLab From 90ffbb162b6c95c16c00153ed45a82f430fa7c39 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 3 Oct 2025 13:46:25 +0530 Subject: [PATCH 35/50] removed consents --- lib/Service/SSOService.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 16658942..0f7c3dd5 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -216,14 +216,6 @@ class SSOService { } } - // Removed: getClientsForRealm; not needed after consent-based approach - - // Removed: revokeOfflineSessionsForClient; not used - - // Removed: getOfflineSessionsForClient; not used - - // Removed: deleteOfflineSession; not used - public function handle2FAStateChange(bool $enabled, string $username) : void { // When state change event is fired by user disabling 2FA, delete existing 2FA credentials and return // i.e. disable 2FA for user at SSO -- GitLab From 66ca76df23840fb4e8c8852e531bf041936f947a Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 3 Oct 2025 13:49:17 +0530 Subject: [PATCH 36/50] reverted changes --- lib/Service/SSOService.php | 137 +++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 0f7c3dd5..a00dfc07 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -110,6 +110,143 @@ class SSOService { } } + + public function logout(string $username) : void { + if($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } + + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; + + $this->logger->debug('logout calling SSO API with url: '. $url); + $this->callSSOAPI($url, 'POST', [], 204); + + // Revoke offline sessions for all clients + try { + $this->revokeOfflineSessionsForUser(); + } catch (\Exception $e) { + $this->logger->warning('Failed to revoke offline sessions: ' . $e->getMessage()); + } + } + + /** + * Revoke all offline sessions for the current user in Keycloak + */ + private function revokeOfflineSessionsForUser(): void { + try { + $clients = $this->getClientsForRealm(); + if (empty($clients)) { + return; + } + + foreach ($clients as $client) { + try { + $this->revokeOfflineSessionsForClient($client); + } catch (\Exception $e) { + $this->logger->warning('Failed to revoke offline sessions for client: ' . (isset($client['id']) ? $client['id'] : 'unknown') . ' - ' . $e->getMessage()); + // Continue with other clients even if one fails + } + } + } catch (\Exception $e) { + $this->logger->warning('Failed to get clients for offline session revocation: ' . $e->getMessage()); + } + } + + /** + * Get all clients for the current realm with caching + */ + private function getClientsForRealm(): array { + // Create unique cache key based on URL (no parameters for this endpoint) + $clientsUrl = $this->ssoConfig['admin_rest_api_url'] . '/clients'; + $cacheKey = "{$clientsUrl}|no_params|no_keys"; + + // Try to get cached clients + $clientsCache = $this->cacheFactory->createDistributed('ecloud_accounts_realm_clients'); + $cachedClients = $clientsCache->get($cacheKey); + if ($cachedClients !== null) { + $this->logger->debug('Returning cached clients from distributed cache'); + return $cachedClients; + } + + try { + $clients = $this->callSSOAPI($clientsUrl, 'GET'); + + if (!is_array($clients)) { + $this->logger->error('Could not fetch clients for offline session revocation.'); + return []; + } + + $clientsCache->set($cacheKey, $clients, self::CLIENTS_CACHE_TTL); + + return $clients; + } catch (\Exception $e) { + $this->logger->error('Failed to fetch clients for offline session revocation: ' . $e->getMessage()); + return []; + } + } + + /** + * Revoke offline sessions for a specific client + */ + private function revokeOfflineSessionsForClient(array $client): void { + if (!isset($client['id'])) { + return; + } + + $clientUUID = $client['id']; + + // Get offline sessions for this client and user + $offlineSessions = $this->getOfflineSessionsForClient($clientUUID); + if (empty($offlineSessions)) { + $this->logger->debug('No offline sessions found for client: ' . $clientUUID); + return; + } + + // Delete each offline session individually + foreach ($offlineSessions as $session) { + $this->deleteOfflineSession($session); + } + } + + /** + * Delete a specific offline session + */ + private function deleteOfflineSession(array $session): void { + if (!isset($session['id'])) { + return; + } + + $sessionId = $session['id']; + $deleteSessionUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId . '?isOffline=true'; + + try { + $this->callSSOAPI($deleteSessionUrl, 'DELETE', [], 204); + } catch (\Exception $e) { + $this->logger->error('Failed to delete offline session ' . $sessionId . ': ' . $e->getMessage()); + } + } + + /** + * Get offline sessions for a specific client and user + */ + private function getOfflineSessionsForClient(string $clientUUID): array { + $offlineSessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/offline-sessions/' . $clientUUID . '?isOffline=true'; + + try { + $offlineSessions = $this->callSSOAPI($offlineSessionsUrl, 'GET'); + + if (!is_array($offlineSessions)) { + return []; + } + + return $offlineSessions; + } catch (\Exception $e) { + $this->logger->error('Failed to fetch offline sessions for client ' . $clientUUID . ': ' . $e->getMessage()); + return []; + } + } + + /** * Logout ALL Keycloak online sessions for the user. Fallback to per-session deletion if bulk logout fails */ -- GitLab From 2cf582e245b374d056e14d393519972fa505fbef Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Fri, 3 Oct 2025 13:51:51 +0530 Subject: [PATCH 37/50] restored existing --- lib/Service/SSOService.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index a00dfc07..3728ba25 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -106,10 +106,10 @@ class SSOService { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); $url .= '/' . $credentialId; + $this->logger->debug('deleteCredentials calling SSO API with url: '. $url); $this->callSSOAPI($url, 'DELETE', [], 204); } } - public function logout(string $username) : void { if($this->isNotCurrentUser($username)) { @@ -368,6 +368,7 @@ class SSOService { private function getCredentialIds() : array { $url = $this->ssoConfig['admin_rest_api_url'] . self::CREDENTIALS_ENDPOINT; $url = str_replace('{USER_ID}', $this->currentUserId, $url); + $this->logger->debug('getCredentialIds calling SSO API with url: '. $url); $credentials = $this->callSSOAPI($url, 'GET'); @@ -437,6 +438,7 @@ class SSOService { } $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '?exact=true&email=' . $email; + $this->logger->debug('getUserId calling SSO API with url: '. $url); $users = $this->callSSOAPI($url, 'GET'); if (empty($users) || !is_array($users) || !isset($users[0])) { throw new SSOAdminAPIException('Error: no user found for search with url: ' . $url); @@ -488,6 +490,7 @@ class SSOService { $headers = [ 'Content-Type: application/x-www-form-urlencoded' ]; + $this->logger->debug('getAdminAccessToken calling SSO API with url: '. $adminAccessTokenRoute . ' and headers: ' . print_r($headers, true) . ' and body: ' . print_r($requestBody, true)); $response = $this->curl->post($adminAccessTokenRoute, $requestBody, $headers); if ($this->curl->getLastStatusCode() !== 200) { -- GitLab From dcb74c7bc4be4761465303c93e0de32df2dc12e6 Mon Sep 17 00:00:00 2001 From: Akhil Date: Mon, 6 Oct 2025 23:49:55 +0530 Subject: [PATCH 38/50] Add script to reload on password reset --- .../BeforeTemplateRenderedListener.php | 4 +++- src/settings-user-security.js | 24 +++++++++++++++++++ webpack.config.js | 1 + 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/settings-user-security.js diff --git a/lib/Listeners/BeforeTemplateRenderedListener.php b/lib/Listeners/BeforeTemplateRenderedListener.php index 622b6ebf..96df1fcf 100644 --- a/lib/Listeners/BeforeTemplateRenderedListener.php +++ b/lib/Listeners/BeforeTemplateRenderedListener.php @@ -47,6 +47,8 @@ class BeforeTemplateRenderedListener implements IEventListener { if (strpos($pathInfo, '/settings/user/migration') !== false) { $this->util->addScript($this->appName, $this->appName . '-settings-user-migration'); } - + if (strpos($pathInfo, '/settings/user/security' !== false)) { + $this->util->addScript($this->appName, $this->appName . '-settings-user-security'); + } } } diff --git a/src/settings-user-security.js b/src/settings-user-security.js new file mode 100644 index 00000000..f6c0f16d --- /dev/null +++ b/src/settings-user-security.js @@ -0,0 +1,24 @@ +(function() { + const OriginalXhr = window.XMLHttpRequest + + /** + * + */ + function PatchedXhr() { + const xhr = new OriginalXhr() + + xhr.addEventListener('load', function() { + if (xhr.responseURL.includes('/settings/personal/changepassword') && xhr.status >= 200 && xhr.status < 300) { + setTimeout(() => window.location.reload(), 1000) + } + }) + + return xhr + } + + // copy prototype to preserve methods + PatchedXhr.prototype = OriginalXhr.prototype + + // replace global XHR + window.XMLHttpRequest = PatchedXhr +})() diff --git a/webpack.config.js b/webpack.config.js index 762d20f7..f9072bdb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,6 +10,7 @@ module.exports = { 'delete-account-listeners': path.join(__dirname, 'src/delete-account-listeners.js'), 'beta-user-setting': path.join(__dirname, 'src/beta-user-setting.js'), 'settings-user-migration': path.join(__dirname, 'src/settings-user-migration.js'), + 'settings-user-security': path.join(__dirname, 'src/settings-user-security.js'), 'signup': path.join(__dirname, 'src/signup.js') }, } -- GitLab From 5357f1c12368a5d22be66bd72b61777e729b58ec Mon Sep 17 00:00:00 2001 From: Akhil Date: Mon, 6 Oct 2025 23:52:13 +0530 Subject: [PATCH 39/50] Temporarily allow staging deployment via pipeline --- .gitlab-ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 08326169..4df49255 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,3 +37,20 @@ build-vendor: artifacts: paths: - dist/ + +deploy:staging: + extends: .deploy:nextcloud-app + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual + - if: $CI_COMMIT_BRANCH == "murena-main" + when: manual + - if: $CI_COMMIT_BRANCH == "production" + when: manual + - if: $CI_COMMIT_BRANCH == "dev/invalidate-session" + when: manual + - if: $CI_COMMIT_TAG + when: manual + environment: + name: staging/01 + url: $ENV_URL -- GitLab From 8440955de7edf91aee29b36f964ed6c6135b2927 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Mon, 6 Oct 2025 23:52:26 +0530 Subject: [PATCH 40/50] cleanup code and used invalidateTokensOfUser --- lib/Listeners/PasswordUpdatedListener.php | 17 ++++----- lib/Service/SSOService.php | 42 +++-------------------- 2 files changed, 11 insertions(+), 48 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index a9969dec..38716a55 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -4,26 +4,20 @@ declare(strict_types=1); namespace OCA\EcloudAccounts\Listeners; +use OC\Authentication\Token\IProvider as TokenProvider; use OCA\EcloudAccounts\Service\SSOService; -use OCP\Authentication\Token\IProvider as TokenProvider; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\IDBConnection; -use OCP\ISession; use OCP\User\Events\PasswordUpdatedEvent; class PasswordUpdatedListener implements IEventListener { private SSOService $ssoService; - private ISession $session; private TokenProvider $tokenProvider; - private IDBConnection $db; - public function __construct(SSOService $ssoService, ISession $session, TokenProvider $tokenProvider, IDBConnection $db) { + public function __construct(SSOService $ssoService, TokenProvider $tokenProvider) { $this->ssoService = $ssoService; - $this->session = $session; $this->tokenProvider = $tokenProvider; - $this->db = $db; } public function handle(Event $event): void { @@ -35,8 +29,11 @@ class PasswordUpdatedListener implements IEventListener { $username = $user->getUID(); // Logout ALL Keycloak sessions $this->ssoService->logoutAllSSOSessionsForUser($username); - // Cleanup ALL Nextcloud tokens - $this->ssoService->logoutNextcloudSessionsViaDb($user, null); + // Cleanup ALL Nextcloud tokens via TokenProvider (invalidates cache and storage) + try { + $this->tokenProvider->invalidateTokensOfUser($username, null); + } catch (\Throwable $e) { + } // Revoke all consents to invalidate offline tokens (prevents eOS from restoring session) try { diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 3728ba25..9bf1dbbb 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -11,7 +11,6 @@ use OCP\ICacheFactory; use OCP\IConfig; use OCP\IDBConnection; use OCP\ILogger; -use OCP\IUser; use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Security\ICrypto; @@ -248,21 +247,21 @@ class SSOService { /** - * Logout ALL Keycloak online sessions for the user. Fallback to per-session deletion if bulk logout fails + * Logout all Keycloak online sessions for the user. Fallback to per-session deletion if logout fails */ public function logoutAllSSOSessionsForUser(string $username): void { if ($this->isNotCurrentUser($username)) { $this->setupUserId($username); } - // Try bulk user logout first + // Try user logout first try { $logoutUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; $this->callSSOAPI($logoutUrl, 'POST', [], 204); - $this->logger->error('====> Performed bulk Keycloak logout for userId=' . $this->currentUserId); + $this->logger->error('Performed Keycloak logout for user ID=' . $this->currentUserId); return; } catch (\Exception $e) { - $this->logger->error('Bulk Keycloak logout failed for userId=' . $this->currentUserId . ': ' . $e->getMessage() . ' - falling back to per-session deletion'); + $this->logger->error('SSO logout failed for user ID=' . $this->currentUserId . ': ' . $e->getMessage() . ' - falling back to per-session deletion'); } // Fallback: list sessions then delete individually @@ -332,27 +331,6 @@ class SSOService { } } - /** - * Set Keycloak user Not-Before to now to invalidate all tokens issued before now - */ - public function setUserNotBeforeNow(string $username): void { - if ($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - $now = time(); - // Use documented endpoint: PUT /admin/realms/{realm}/users/{id} with notBefore field - $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; - $payload = ['notBefore' => $now]; - try { - $this->logger->error('====> Setting user not-before to ' . $now . ' (userId=' . $this->currentUserId . ')'); - $this->callSSOAPI($url, 'PUT', $payload, 204); - $this->logger->error('====> Set user not-before done (userId=' . $this->currentUserId . ')'); - } catch (\Exception $e) { - $this->logger->error('Failed to set user not-before for userId=' . $this->currentUserId . ': ' . $e->getMessage()); - } - } - public function handle2FAStateChange(bool $enabled, string $username) : void { // When state change event is fired by user disabling 2FA, delete existing 2FA credentials and return // i.e. disable 2FA for user at SSO @@ -563,16 +541,4 @@ class SSOService { return !(!empty($this->currentUserId) && !empty($this->currentUserName) && $username === $this->currentUserName); } - /** - * Remove all Nextcloud auth tokens (authtoken) for a user except the provided current token id - */ - public function logoutNextcloudSessionsViaDb(IUser $user, ?string $keepTokenId): void { - $qb = $this->db->getQueryBuilder(); - $qb->delete('authtoken') - ->where($qb->expr()->eq('uid', $qb->createNamedParameter($user->getUID()))); - if ($keepTokenId !== null && $keepTokenId !== '') { - $qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($keepTokenId))); - } - $qb->executeStatement(); - } } -- GitLab From fdbef4a51031f330b24727634efb4002584a168f Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Mon, 6 Oct 2025 23:54:39 +0530 Subject: [PATCH 41/50] cleanup code --- lib/Service/SSOService.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 9bf1dbbb..847efa5b 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -269,7 +269,7 @@ class SSOService { $sessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; $sessions = $this->callSSOAPI($sessionsUrl, 'GET'); if (!is_array($sessions) || empty($sessions)) { - $this->logger->error('====> No online sessions found to delete for userId=' . $this->currentUserId); + $this->logger->error('No online sessions found to delete for userId=' . $this->currentUserId); return; } foreach ($sessions as $session) { @@ -279,7 +279,7 @@ class SSOService { $sessionId = (string)$session['id']; $deleteUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; try { - $this->logger->error('====> Deleting online session ' . $sessionId . ' for userId=' . $this->currentUserId); + $this->logger->error('Deleting online session ' . $sessionId . ' for userId=' . $this->currentUserId); $this->callSSOAPI($deleteUrl, 'DELETE', [], 204); } catch (\Exception $e) { $this->logger->error('Failed to delete online session ' . $sessionId . ': ' . $e->getMessage()); @@ -303,29 +303,25 @@ class SSOService { $consentsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents'; $consents = $this->callSSOAPI($consentsUrl, 'GET'); if (!is_array($consents) || empty($consents)) { - $this->logger->error('====> No user consents found (userId=' . $this->currentUserId . ')'); + $this->logger->error('No user consents found (userId=' . $this->currentUserId . ')'); return; } - $this->logger->error('====> Consents fetched (count=' . count($consents) . ') for userId=' . $this->currentUserId . ': ' . json_encode($consents)); $deletedClients = []; foreach ($consents as $consent) { - // Keycloak returns OAuthClientConsentRepresentation with 'clientId' if (!isset($consent['clientId']) || !is_string($consent['clientId']) || $consent['clientId'] === '') { continue; } $clientId = $consent['clientId']; - $this->logger->error('====> Revoking user consent for client ' . $clientId . ' (userId=' . $this->currentUserId . ')'); + $deleteConsentUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents/' . rawurlencode($clientId); try { $this->callSSOAPI($deleteConsentUrl, 'DELETE', [], 204); - $this->logger->error('====> Deleted consent for client ' . $clientId . ' (userId=' . $this->currentUserId . ')'); $deletedClients[] = $clientId; } catch (\Exception $e) { $this->logger->error('Failed to delete consent for client ' . $clientId . ': ' . $e->getMessage()); } } - $this->logger->error('====> Revoked consents summary (userId=' . $this->currentUserId . '): ' . json_encode($deletedClients)); } catch (\Exception $e) { $this->logger->error('Failed to fetch user consents for user ' . $this->currentUserId . ': ' . $e->getMessage()); } -- GitLab From 7146a9c79a52fe281185fdda866398a48df44a75 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Mon, 6 Oct 2025 23:55:26 +0530 Subject: [PATCH 42/50] cleanup code --- lib/Service/SSOService.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 847efa5b..55da6bbe 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -269,7 +269,7 @@ class SSOService { $sessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; $sessions = $this->callSSOAPI($sessionsUrl, 'GET'); if (!is_array($sessions) || empty($sessions)) { - $this->logger->error('No online sessions found to delete for userId=' . $this->currentUserId); + $this->logger->error('No online sessions found to delete for user ID=' . $this->currentUserId); return; } foreach ($sessions as $session) { @@ -279,14 +279,14 @@ class SSOService { $sessionId = (string)$session['id']; $deleteUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; try { - $this->logger->error('Deleting online session ' . $sessionId . ' for userId=' . $this->currentUserId); + $this->logger->error('Deleting online session ' . $sessionId . ' for user ID=' . $this->currentUserId); $this->callSSOAPI($deleteUrl, 'DELETE', [], 204); } catch (\Exception $e) { $this->logger->error('Failed to delete online session ' . $sessionId . ': ' . $e->getMessage()); } } } catch (\Exception $e) { - $this->logger->error('Failed to fetch sessions for userId=' . $this->currentUserId . ': ' . $e->getMessage()); + $this->logger->error('Failed to fetch sessions for user ID=' . $this->currentUserId . ': ' . $e->getMessage()); } } @@ -303,7 +303,7 @@ class SSOService { $consentsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents'; $consents = $this->callSSOAPI($consentsUrl, 'GET'); if (!is_array($consents) || empty($consents)) { - $this->logger->error('No user consents found (userId=' . $this->currentUserId . ')'); + $this->logger->error('No user consents found (user ID=' . $this->currentUserId . ')'); return; } -- GitLab From 5a17c452ab255492fdbad50d8cac03f407eb6371 Mon Sep 17 00:00:00 2001 From: Akhil Date: Mon, 6 Oct 2025 23:57:36 +0530 Subject: [PATCH 43/50] Fix check to deliver security page script --- lib/Listeners/BeforeTemplateRenderedListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Listeners/BeforeTemplateRenderedListener.php b/lib/Listeners/BeforeTemplateRenderedListener.php index 96df1fcf..0b1202bf 100644 --- a/lib/Listeners/BeforeTemplateRenderedListener.php +++ b/lib/Listeners/BeforeTemplateRenderedListener.php @@ -47,7 +47,7 @@ class BeforeTemplateRenderedListener implements IEventListener { if (strpos($pathInfo, '/settings/user/migration') !== false) { $this->util->addScript($this->appName, $this->appName . '-settings-user-migration'); } - if (strpos($pathInfo, '/settings/user/security' !== false)) { + if (strpos($pathInfo, '/settings/user/security') !== false) { $this->util->addScript($this->appName, $this->appName . '-settings-user-security'); } } -- GitLab From 4644eb7e087c18332ab7ebe34ac4f60c10c7855b Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 7 Oct 2025 12:01:38 +0530 Subject: [PATCH 44/50] added not before now --- lib/Listeners/PasswordUpdatedListener.php | 6 ++++++ lib/Service/SSOService.php | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 38716a55..ddb28c8b 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -41,6 +41,12 @@ class PasswordUpdatedListener implements IEventListener { } catch (\Throwable $e) { } + // Finally, set Keycloak Not-Before to invalidate any tokens issued before now + try { + $this->ssoService->setUserNotBeforeNow($username); + } catch (\Throwable $e) { + } + } } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 55da6bbe..1b57d45f 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -327,6 +327,26 @@ class SSOService { } } + /** + * Set Keycloak user Not-Before to now to invalidate all tokens issued before now + */ + public function setUserNotBeforeNow(string $username): void { + if ($this->isNotCurrentUser($username)) { + $this->setupUserId($username); + } + + $now = time(); + $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; + $payload = ['notBefore' => $now]; + try { + $this->logger->error('Setting user not-before to ' . $now . ' for user ID=' . $this->currentUserId); + $this->callSSOAPI($url, 'PUT', $payload, 204); + $this->logger->error('Set user not-before done for user ID=' . $this->currentUserId); + } catch (\Exception $e) { + $this->logger->error('Failed to set user not-before for user ID=' . $this->currentUserId . ': ' . $e->getMessage()); + } + } + public function handle2FAStateChange(bool $enabled, string $username) : void { // When state change event is fired by user disabling 2FA, delete existing 2FA credentials and return // i.e. disable 2FA for user at SSO -- GitLab From f4af1c54c1217ccd1ded544795500ca19ddf8b91 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Tue, 7 Oct 2025 23:41:40 +0530 Subject: [PATCH 45/50] reverted code --- lib/Listeners/PasswordUpdatedListener.php | 42 ++++--- lib/Service/SSOService.php | 130 ++-------------------- 2 files changed, 31 insertions(+), 141 deletions(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index ddb28c8b..8d1dd8f8 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -4,20 +4,29 @@ declare(strict_types=1); namespace OCA\EcloudAccounts\Listeners; -use OC\Authentication\Token\IProvider as TokenProvider; +use Exception; +use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Service\SSOService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\ILogger; +use OCP\ISession; +use OCP\IUserSession; use OCP\User\Events\PasswordUpdatedEvent; class PasswordUpdatedListener implements IEventListener { private SSOService $ssoService; - private TokenProvider $tokenProvider; - public function __construct(SSOService $ssoService, TokenProvider $tokenProvider) { + private ILogger $logger; + private ISession $session; + private IUserSession $userSession; + + public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession) { $this->ssoService = $ssoService; - $this->tokenProvider = $tokenProvider; + $this->logger = $logger; + $this->session = $session; + $this->userSession = $userSession; } public function handle(Event $event): void { @@ -25,28 +34,17 @@ class PasswordUpdatedListener implements IEventListener { return; } - $user = $event->getUser(); - $username = $user->getUID(); - // Logout ALL Keycloak sessions - $this->ssoService->logoutAllSSOSessionsForUser($username); - // Cleanup ALL Nextcloud tokens via TokenProvider (invalidates cache and storage) - try { - $this->tokenProvider->invalidateTokensOfUser($username, null); - } catch (\Throwable $e) { + if (!$this->userSession->isLoggedIn() || !$this->session->exists('is_oidc')) { + return; } - // Revoke all consents to invalidate offline tokens (prevents eOS from restoring session) - try { - $this->ssoService->revokeAllConsentsForUser($username); - } catch (\Throwable $e) { - } + $user = $event->getUser(); + $username = $user->getUID(); - // Finally, set Keycloak Not-Before to invalidate any tokens issued before now try { - $this->ssoService->setUserNotBeforeNow($username); - } catch (\Throwable $e) { + $this->ssoService->logout($username); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); } - } - } diff --git a/lib/Service/SSOService.php b/lib/Service/SSOService.php index 1b57d45f..03acaf21 100644 --- a/lib/Service/SSOService.php +++ b/lib/Service/SSOService.php @@ -9,7 +9,6 @@ use OCA\EcloudAccounts\Exception\SSOAdminAccessTokenException; use OCA\EcloudAccounts\Exception\SSOAdminAPIException; use OCP\ICacheFactory; use OCP\IConfig; -use OCP\IDBConnection; use OCP\ILogger; use OCP\IUserManager; use OCP\L10N\IFactory; @@ -30,7 +29,6 @@ class SSOService { private IUserManager $userManager; private TwoFactorMapper $twoFactorMapper; private ICacheFactory $cacheFactory; - private IDBConnection $db; private string $mainDomain; private string $legacyDomain; @@ -40,7 +38,7 @@ class SSOService { private const CREDENTIALS_ENDPOINT = '/users/{USER_ID}/credentials'; private const CLIENTS_CACHE_TTL = 6 * 3600; // 6 hours - public function __construct($appName, IConfig $config, CurlService $curlService, ICrypto $crypto, IFactory $l10nFactory, IUserManager $userManager, TwoFactorMapper $twoFactorMapper, ILogger $logger, ICacheFactory $cacheFactory, IDBConnection $db) { + public function __construct($appName, IConfig $config, CurlService $curlService, ICrypto $crypto, IFactory $l10nFactory, IUserManager $userManager, TwoFactorMapper $twoFactorMapper, ILogger $logger, ICacheFactory $cacheFactory) { $this->appName = $appName; $this->config = $config; @@ -53,10 +51,7 @@ class SSOService { $this->ssoConfig['admin_client_secret'] = $this->config->getSystemValue('oidc_admin_client_secret', ''); $this->ssoConfig['admin_username'] = $this->config->getSystemValue('oidc_admin_username', ''); $this->ssoConfig['admin_password'] = $this->config->getSystemValue('oidc_admin_password', ''); - $this->ssoConfig['login_client_id'] = $this->config->getSystemValue('oidc_login_client_id', ''); - $this->ssoConfig['login_client_secret'] = $this->config->getSystemValue('oidc_login_client_secret', ''); $this->ssoConfig['admin_rest_api_url'] = $rootUrl . '/auth/admin' . $realmsPart; - $this->ssoConfig['introspect_url'] = $rootUrl . '/auth' . $realmsPart . '/protocol/openid-connect/token/introspect'; $this->ssoConfig['root_url'] = $rootUrl; $this->crypto = $crypto; $this->curl = $curlService; @@ -65,7 +60,6 @@ class SSOService { $this->userManager = $userManager; $this->twoFactorMapper = $twoFactorMapper; $this->cacheFactory = $cacheFactory; - $this->db = $db; $this->mainDomain = $this->config->getSystemValue("main_domain"); $this->legacyDomain = $this->config->getSystemValue("legacy_domain"); @@ -91,6 +85,7 @@ class SSOService { 'credentials' => [$credentialEntry] ]; + $this->logger->debug('migrateCredential calling SSO API with url: '. $url . ' and data: ' . print_r($data, true)); $this->callSSOAPI($url, 'PUT', $data, 204); } @@ -109,7 +104,7 @@ class SSOService { $this->callSSOAPI($url, 'DELETE', [], 204); } } - + public function logout(string $username) : void { if($this->isNotCurrentUser($username)) { $this->setupUserId($username); @@ -207,24 +202,6 @@ class SSOService { } } - /** - * Delete a specific offline session - */ - private function deleteOfflineSession(array $session): void { - if (!isset($session['id'])) { - return; - } - - $sessionId = $session['id']; - $deleteSessionUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId . '?isOffline=true'; - - try { - $this->callSSOAPI($deleteSessionUrl, 'DELETE', [], 204); - } catch (\Exception $e) { - $this->logger->error('Failed to delete offline session ' . $sessionId . ': ' . $e->getMessage()); - } - } - /** * Get offline sessions for a specific client and user */ @@ -245,105 +222,21 @@ class SSOService { } } - /** - * Logout all Keycloak online sessions for the user. Fallback to per-session deletion if logout fails + * Delete a specific offline session */ - public function logoutAllSSOSessionsForUser(string $username): void { - if ($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - // Try user logout first - try { - $logoutUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/logout'; - $this->callSSOAPI($logoutUrl, 'POST', [], 204); - $this->logger->error('Performed Keycloak logout for user ID=' . $this->currentUserId); + private function deleteOfflineSession(array $session): void { + if (!isset($session['id'])) { return; - } catch (\Exception $e) { - $this->logger->error('SSO logout failed for user ID=' . $this->currentUserId . ': ' . $e->getMessage() . ' - falling back to per-session deletion'); } - // Fallback: list sessions then delete individually - try { - $sessionsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/sessions'; - $sessions = $this->callSSOAPI($sessionsUrl, 'GET'); - if (!is_array($sessions) || empty($sessions)) { - $this->logger->error('No online sessions found to delete for user ID=' . $this->currentUserId); - return; - } - foreach ($sessions as $session) { - if (!isset($session['id'])) { - continue; - } - $sessionId = (string)$session['id']; - $deleteUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId; - try { - $this->logger->error('Deleting online session ' . $sessionId . ' for user ID=' . $this->currentUserId); - $this->callSSOAPI($deleteUrl, 'DELETE', [], 204); - } catch (\Exception $e) { - $this->logger->error('Failed to delete online session ' . $sessionId . ': ' . $e->getMessage()); - } - } - } catch (\Exception $e) { - $this->logger->error('Failed to fetch sessions for user ID=' . $this->currentUserId . ': ' . $e->getMessage()); - } - } - - - /** - * Revoke all user consents to invalidate offline tokens for all clients - */ - public function revokeAllConsentsForUser(string $username): void { - if ($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - try { - $consentsUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents'; - $consents = $this->callSSOAPI($consentsUrl, 'GET'); - if (!is_array($consents) || empty($consents)) { - $this->logger->error('No user consents found (user ID=' . $this->currentUserId . ')'); - return; - } - - $deletedClients = []; - foreach ($consents as $consent) { - if (!isset($consent['clientId']) || !is_string($consent['clientId']) || $consent['clientId'] === '') { - continue; - } - $clientId = $consent['clientId']; - - $deleteConsentUrl = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId . '/consents/' . rawurlencode($clientId); - try { - $this->callSSOAPI($deleteConsentUrl, 'DELETE', [], 204); - $deletedClients[] = $clientId; - } catch (\Exception $e) { - $this->logger->error('Failed to delete consent for client ' . $clientId . ': ' . $e->getMessage()); - } - } - } catch (\Exception $e) { - $this->logger->error('Failed to fetch user consents for user ' . $this->currentUserId . ': ' . $e->getMessage()); - } - } - - /** - * Set Keycloak user Not-Before to now to invalidate all tokens issued before now - */ - public function setUserNotBeforeNow(string $username): void { - if ($this->isNotCurrentUser($username)) { - $this->setupUserId($username); - } - - $now = time(); - $url = $this->ssoConfig['admin_rest_api_url'] . self::USERS_ENDPOINT . '/' . $this->currentUserId; - $payload = ['notBefore' => $now]; + $sessionId = $session['id']; + $deleteSessionUrl = $this->ssoConfig['admin_rest_api_url'] . '/sessions/' . $sessionId . '?isOffline=true'; + try { - $this->logger->error('Setting user not-before to ' . $now . ' for user ID=' . $this->currentUserId); - $this->callSSOAPI($url, 'PUT', $payload, 204); - $this->logger->error('Set user not-before done for user ID=' . $this->currentUserId); + $this->callSSOAPI($deleteSessionUrl, 'DELETE', [], 204); } catch (\Exception $e) { - $this->logger->error('Failed to set user not-before for user ID=' . $this->currentUserId . ': ' . $e->getMessage()); + $this->logger->error('Failed to delete offline session ' . $sessionId . ': ' . $e->getMessage()); } } @@ -556,5 +449,4 @@ class SSOService { $username = $this->sanitizeUserName($username); return !(!empty($this->currentUserId) && !empty($this->currentUserName) && $username === $this->currentUserName); } - } -- GitLab From 8b0d5dbad8856e06cea26ba385e2bf37918e2644 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 8 Oct 2025 17:28:51 +0530 Subject: [PATCH 46/50] removing all nextcloud tokens --- lib/Listeners/PasswordUpdatedListener.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 8d1dd8f8..88b71854 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace OCA\EcloudAccounts\Listeners; use Exception; +use OC\Authentication\Token\IProvider as TokenProvider; use OCA\EcloudAccounts\AppInfo\Application; use OCA\EcloudAccounts\Service\SSOService; use OCP\EventDispatcher\Event; @@ -21,12 +22,14 @@ class PasswordUpdatedListener implements IEventListener { private ILogger $logger; private ISession $session; private IUserSession $userSession; + private TokenProvider $tokenProvider; - public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession) { + public function __construct(SSOService $ssoService, ILogger $logger, ISession $session, IUserSession $userSession, TokenProvider $tokenProvider) { $this->ssoService = $ssoService; $this->logger = $logger; $this->session = $session; $this->userSession = $userSession; + $this->tokenProvider = $tokenProvider; } public function handle(Event $event): void { @@ -46,5 +49,12 @@ class PasswordUpdatedListener implements IEventListener { } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); } + + // Remove all Nextcloud sessions/tokens for the user (invalidate cache + storage) + try { + $this->tokenProvider->invalidateTokensOfUser($username, null); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } } } -- GitLab From 9a7b73623dc1b7e375414c0dbe3e49ef2f061430 Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Wed, 8 Oct 2025 17:30:16 +0530 Subject: [PATCH 47/50] current session out --- lib/Listeners/PasswordUpdatedListener.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/Listeners/PasswordUpdatedListener.php b/lib/Listeners/PasswordUpdatedListener.php index 88b71854..85b260ee 100644 --- a/lib/Listeners/PasswordUpdatedListener.php +++ b/lib/Listeners/PasswordUpdatedListener.php @@ -56,5 +56,12 @@ class PasswordUpdatedListener implements IEventListener { } catch (Exception $e) { $this->logger->logException($e, ['app' => Application::APP_ID]); } + + // Finally, log out the current session (also clears remember-me cookies) + try { + $this->userSession->logout(); + } catch (Exception $e) { + $this->logger->logException($e, ['app' => Application::APP_ID]); + } } } -- GitLab From dbd65b57998a55955653c8432d2ea3f4a4f85aed Mon Sep 17 00:00:00 2001 From: theronakpatel Date: Thu, 9 Oct 2025 10:45:28 +0530 Subject: [PATCH 48/50] gitlab ci --- .gitlab-ci.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4df49255..08326169 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,20 +37,3 @@ build-vendor: artifacts: paths: - dist/ - -deploy:staging: - extends: .deploy:nextcloud-app - rules: - - if: $CI_COMMIT_BRANCH == "main" - when: manual - - if: $CI_COMMIT_BRANCH == "murena-main" - when: manual - - if: $CI_COMMIT_BRANCH == "production" - when: manual - - if: $CI_COMMIT_BRANCH == "dev/invalidate-session" - when: manual - - if: $CI_COMMIT_TAG - when: manual - environment: - name: staging/01 - url: $ENV_URL -- GitLab From 66dc5a5db6c8b6cc9bb9585883765bf605a407b8 Mon Sep 17 00:00:00 2001 From: Ronak Patel Date: Thu, 9 Oct 2025 12:46:20 +0530 Subject: [PATCH 49/50] Apply 1 suggestion(s) to 1 file(s) --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index daa131fb..d54d53e4 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,7 +10,7 @@ - 12.1.0 + 12.0.1 agpl Murena SAS EcloudAccounts -- GitLab From e90244d1573b843f3d4d3ed47111456f68101c96 Mon Sep 17 00:00:00 2001 From: Akhil Date: Thu, 9 Oct 2025 15:14:23 +0530 Subject: [PATCH 50/50] (docs) add explanation about script to reload --- src/settings-user-security.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/settings-user-security.js b/src/settings-user-security.js index f6c0f16d..809e9cda 100644 --- a/src/settings-user-security.js +++ b/src/settings-user-security.js @@ -1,12 +1,10 @@ (function() { const OriginalXhr = window.XMLHttpRequest - /** - * - */ function PatchedXhr() { const xhr = new OriginalXhr() + // We want to reload the page if password change request is successful xhr.addEventListener('load', function() { if (xhr.responseURL.includes('/settings/personal/changepassword') && xhr.status >= 200 && xhr.status < 300) { setTimeout(() => window.location.reload(), 1000) -- GitLab