diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d3be011c..bd199228 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -13,9 +13,12 @@ use OC_User; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Event\ExchangedTokenRequestedEvent; +use OCA\UserOIDC\Listener\ExchangedTokenRequestedListener; use OCA\UserOIDC\Listener\TimezoneHandlingListener; use OCA\UserOIDC\Service\ID4MeService; use OCA\UserOIDC\Service\SettingsService; +use OCA\UserOIDC\Service\TokenService; use OCA\UserOIDC\User\Backend; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -52,10 +55,12 @@ public function register(IRegistrationContext $context): void { OC_User::useBackend($this->backend); $context->registerEventListener(LoadAdditionalScriptsEvent::class, TimezoneHandlingListener::class); + $context->registerEventListener(ExchangedTokenRequestedEvent::class, ExchangedTokenRequestedListener::class); } public function boot(IBootContext $context): void { $context->injectFn(\Closure::fromCallable([$this->backend, 'injectSession'])); + $context->injectFn(\Closure::fromCallable([$this, 'checkLoginToken'])); /** @var IUserSession $userSession */ $userSession = $this->getContainer()->get(IUserSession::class); if ($userSession->isLoggedIn()) { @@ -69,6 +74,10 @@ public function boot(IBootContext $context): void { } } + private function checkLoginToken(TokenService $tokenService): void { + $tokenService->checkLoginToken(); + } + private function registerRedirect(IRequest $request, IURLGenerator $urlGenerator, SettingsService $settings, ProviderMapper $providerMapper): void { $providers = $this->getCachedProviders($providerMapper); $redirectUrl = $request->getParam('redirect_url'); diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 970ef16e..f7911757 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -24,6 +24,7 @@ use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\ProviderService; use OCA\UserOIDC\Service\ProvisioningService; +use OCA\UserOIDC\Service\TokenService; use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -77,6 +78,7 @@ public function __construct( private IL10N $l10n, private LoggerInterface $logger, private ICrypto $crypto, + private TokenService $tokenService, ) { parent::__construct($request, $config); } @@ -496,6 +498,14 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->userSession->createRememberMeToken($user); } + // store all token information for potential token exchange requests + $tokenData = array_merge( + $data, + ['provider_id' => $providerId], + ); + $this->tokenService->storeToken($tokenData); + $this->config->setUserValue($user->getUID(), Application::APP_ID, 'had_token_once', '1'); + // Set last password confirm to the future as we don't have passwords to confirm against with SSO $this->session->set('last-password-confirm', strtotime('+4 year', time())); diff --git a/lib/Event/ExchangedTokenRequestedEvent.php b/lib/Event/ExchangedTokenRequestedEvent.php new file mode 100644 index 00000000..0c98ab3c --- /dev/null +++ b/lib/Event/ExchangedTokenRequestedEvent.php @@ -0,0 +1,59 @@ + + * + * @author Julien Veyssier + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +use OCA\UserOIDC\Model\Token; +use OCP\EventDispatcher\Event; + +/** + * This event is emitted with by other apps which need an exchanged token for another audience (another client ID) + */ +class ExchangedTokenRequestedEvent extends Event { + + private ?Token $token = null; + + public function __construct( + private string $targetAudience, + ) { + parent::__construct(); + } + + public function getTargetAudience(): string { + return $this->targetAudience; + } + + public function setTargetAudience(string $targetAudience): void { + $this->targetAudience = $targetAudience; + } + + public function getToken(): ?Token { + return $this->token; + } + + public function setToken(?Token $token): void { + $this->token = $token; + } +} diff --git a/lib/Exception/TokenExchangeFailedException.php b/lib/Exception/TokenExchangeFailedException.php new file mode 100644 index 00000000..5cb600d5 --- /dev/null +++ b/lib/Exception/TokenExchangeFailedException.php @@ -0,0 +1,15 @@ + + * + * @author Julien Veyssier + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\UserOIDC\Listener; + +use OCA\UserOIDC\Event\ExchangedTokenRequestedEvent; +use OCA\UserOIDC\Service\TokenService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * @implements IEventListener + */ +class ExchangedTokenRequestedListener implements IEventListener { + + public function __construct( + private IUserSession $userSession, + private TokenService $tokenService, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof ExchangedTokenRequestedEvent) { + return; + } + + if (!$this->userSession->isLoggedIn()) { + return; + } + + $targetAudience = $event->getTargetAudience(); + $this->logger->debug('[TokenExchange Listener] received request for audience: ' . $targetAudience); + $token = $this->tokenService->getExchangedToken($targetAudience); + $event->setToken($token); + } +} diff --git a/lib/Model/Token.php b/lib/Model/Token.php new file mode 100644 index 00000000..bfbbb153 --- /dev/null +++ b/lib/Model/Token.php @@ -0,0 +1,115 @@ + + * + * @author Julien Veyssier + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Model; + +use JsonSerializable; + +class Token implements JsonSerializable { + + private string $idToken; + private string $accessToken; + private int $expiresIn; + private int $refreshExpiresIn; + private string $refreshToken; + private int $createdAt; + private ?int $providerId; + + public function __construct(array $tokenData) { + $this->idToken = $tokenData['id_token']; + $this->accessToken = $tokenData['access_token']; + $this->expiresIn = $tokenData['expires_in']; + $this->refreshExpiresIn = $tokenData['refresh_expires_in']; + $this->refreshToken = $tokenData['refresh_token']; + $this->createdAt = $tokenData['created_at'] ?? time(); + $this->providerId = $tokenData['provider_id'] ?? null; + } + + public function getAccessToken(): string { + return $this->accessToken; + } + + public function getIdToken(): string { + return $this->idToken; + } + + public function getExpiresIn(): int { + return $this->expiresIn; + } + + public function getExpiresInFromNow(): int { + $expiresAt = $this->createdAt + $this->expiresIn; + return $expiresAt - time(); + } + + public function getRefreshExpiresIn(): int { + return $this->refreshExpiresIn; + } + + public function getRefreshExpiresInFromNow(): int { + $refreshExpiresAt = $this->createdAt + $this->refreshExpiresIn; + return $refreshExpiresAt - time(); + } + + public function getRefreshToken(): string { + return $this->refreshToken; + } + + public function getProviderId(): ?int { + return $this->providerId; + } + + public function isExpired(): bool { + return time() > ($this->createdAt + $this->expiresIn); + } + + public function isExpiring(): bool { + return time() > ($this->createdAt + (int)($this->expiresIn / 2)); + } + + public function refreshIsExpired(): bool { + return time() > ($this->createdAt + $this->refreshExpiresIn); + } + + public function refreshIsExpiring(): bool { + return time() > ($this->createdAt + (int)($this->refreshExpiresIn / 2)); + } + + public function getCreatedAt() { + return $this->createdAt; + } + + public function jsonSerialize(): array { + return [ + 'id_token' => $this->idToken, + 'access_token' => $this->accessToken, + 'expires_in' => $this->expiresIn, + 'refresh_expires_in' => $this->refreshExpiresIn, + 'refresh_token' => $this->refreshToken, + 'created_at' => $this->createdAt, + 'provider_id' => $this->providerId, + ]; + } +} diff --git a/lib/Service/TokenService.php b/lib/Service/TokenService.php new file mode 100644 index 00000000..5689c36a --- /dev/null +++ b/lib/Service/TokenService.php @@ -0,0 +1,293 @@ + + * + * @author Julien Veyssier + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Service; + +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Exception\TokenExchangeFailedException; +use OCA\UserOIDC\Model\Token; +use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\PreConditionNotMetException; +use OCP\Security\ICrypto; +use Psr\Log\LoggerInterface; + +/** + * Token management service + * This is helpful to debug: + * tail -f data/nextcloud.log | grep "\[Token" | jq ".time,.message" + */ +class TokenService { + + private const SESSION_TOKEN_KEY = Application::APP_ID . '-user-token'; + + private IClient $client; + + public function __construct( + IClientService $clientService, + private ISession $session, + private IUserSession $userSession, + private IConfig $config, + private LoggerInterface $logger, + private ICrypto $crypto, + private IRequest $request, + private IURLGenerator $urlGenerator, + private DiscoveryService $discoveryService, + private ProviderMapper $providerMapper, + ) { + $this->client = $clientService->newClient(); + } + + public function storeToken(array $tokenData): Token { + $token = new Token($tokenData); + $this->session->set(self::SESSION_TOKEN_KEY, json_encode($token, JSON_THROW_ON_ERROR)); + $this->logger->debug('[TokenService] Store token'); + return $token; + } + + /** + * Get the token stored in the session + * If it has expired: try to refresh it + * + * @param bool $refreshIfExpired + * @return Token|null Return a token only if it is valid or has been successfully refreshed + * @throws \JsonException + */ + public function getToken(bool $refreshIfExpired = true): ?Token { + $sessionData = $this->session->get(self::SESSION_TOKEN_KEY); + if (!$sessionData) { + $this->logger->debug('[TokenService] getToken: no session data'); + return null; + } + + $token = new Token(json_decode($sessionData, true, 512, JSON_THROW_ON_ERROR)); + // token is still valid + if (!$token->isExpired()) { + $this->logger->debug('[TokenService] getToken: token is still valid, it expires in ' . $token->getExpiresInFromNow() . ' and refresh expires in ' . $token->getRefreshExpiresInFromNow()); + return $token; + } + + // token has expired + // try to refresh the token if the refresh token is still valid + if ($refreshIfExpired && !$token->refreshIsExpired()) { + $this->logger->debug('[TokenService] getToken: token is expired and refresh token is still valid, refresh expires in ' . $token->getRefreshExpiresInFromNow()); + return $this->refresh($token); + } + + $this->logger->debug('[TokenService] getToken: return a token that has not been refreshed'); + return $token; + } + + /** + * Check to make sure the login token is still valid + * + * @return void + * @throws \JsonException + * @throws PreConditionNotMetException + */ + public function checkLoginToken(): void { + $currentUser = $this->userSession->getUser(); + if (!$this->userSession->isLoggedIn() || $currentUser === null) { + $this->logger->debug('[TokenService] checkLoginToken: user not logged in'); + return; + } + if ($this->config->getUserValue($currentUser->getUID(), Application::APP_ID, 'had_token_once', '0') !== '1') { + $this->logger->debug('[TokenService] checkLoginToken: we never had a token before, check not needed'); + return; + } + + $token = $this->getToken(); + if ($token === null) { + $this->logger->debug('[TokenService] checkLoginToken: token is null'); + // if we don't have a token but we had one once, + // it means the session (where we store the token) has died + // so we need to reauthenticate + $this->logger->debug('[TokenService] checkLoginToken: token is null and user had_token_once -> logout'); + $this->userSession->logout(); + } elseif ($token->isExpired()) { + $this->logger->debug('[TokenService] checkLoginToken: token is still expired -> reauthenticate'); + // if the token is not valid, it means we couldn't refresh it so we need to reauthenticate to get a fresh token + $this->reauthenticate($token->getProviderId()); + } + } + + public function reauthenticate(int $providerId) { + // Logout the user and redirect to the oidc login flow to gather a fresh token + $this->userSession->logout(); + $redirectUrl = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.login', [ + 'providerId' => $providerId, + 'redirectUrl' => $this->request->getRequestUri(), + ]); + header('Location: ' . $redirectUrl); + $this->logger->debug('[TokenService] reauthenticate', ['redirectUrl' => $redirectUrl]); + exit(); + } + + /** + * @param Token $token + * @return Token + * @throws \JsonException + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function refresh(Token $token): Token { + $oidcProvider = $this->providerMapper->getProvider($token->getProviderId()); + $discovery = $this->discoveryService->obtainDiscovery($oidcProvider); + + try { + $clientSecret = $oidcProvider->getClientSecret(); + if ($clientSecret !== '') { + try { + $clientSecret = $this->crypto->decrypt($clientSecret); + } catch (\Exception $e) { + $this->logger->error('[TokenService] Failed to decrypt oidc client secret to refresh the token'); + } + } + $this->logger->debug('[TokenService] Refreshing the token: ' . $discovery['token_endpoint']); + $result = $this->client->post( + $discovery['token_endpoint'], + [ + 'body' => [ + 'client_id' => $oidcProvider->getClientId(), + 'client_secret' => $clientSecret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $token->getRefreshToken(), + ], + ] + ); + $this->logger->debug('[TokenService] Token refresh request params', [ + 'client_id' => $oidcProvider->getClientId(), + // 'client_secret' => $clientSecret, + 'grant_type' => 'refresh_token', + // 'refresh_token' => $token->getRefreshToken(), + ]); + $body = $result->getBody(); + $bodyArray = json_decode(trim($body), true, 512, JSON_THROW_ON_ERROR); + $this->logger->debug('[TokenService] ---- Refresh token success'); + return $this->storeToken( + array_merge( + $bodyArray, + ['provider_id' => $token->getProviderId()], + ) + ); + } catch (\Exception $e) { + $this->logger->error('[TokenService] Failed to refresh token ', ['exception' => $e]); + // Failed to refresh, return old token which will be retried or otherwise timeout if expired + return $token; + } + } + + public function decodeIdToken(Token $token): array { + $provider = $this->providerMapper->getProvider($token->getProviderId()); + $jwks = $this->discoveryService->obtainJWK($provider, $token->getIdToken()); + JWT::$leeway = 60; + $idTokenObject = JWT::decode($token->getIdToken(), $jwks); + return json_decode(json_encode($idTokenObject), true); + } + + /** + * Exchange a token for another audience (client ID) + * + * @param string $targetAudience + * @return Token + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws TokenExchangeFailedException + * @throws \JsonException + */ + public function getExchangedToken(string $targetAudience): Token { + $loginToken = $this->getToken(); + if ($loginToken === null) { + $this->logger->debug('[TokenService] Failed to exchange token, no login token found in the session'); + throw new TokenExchangeFailedException('Failed to exchange token, no login token found in the session'); + } + if ($loginToken->isExpired()) { + $this->logger->debug('[TokenService] Failed to exchange token, the login token is expired'); + throw new TokenExchangeFailedException('Failed to exchange token, the login token is expired'); + } + $oidcProvider = $this->providerMapper->getProvider($loginToken->getProviderId()); + $discovery = $this->discoveryService->obtainDiscovery($oidcProvider); + + try { + $clientSecret = $oidcProvider->getClientSecret(); + if ($clientSecret !== '') { + try { + $clientSecret = $this->crypto->decrypt($clientSecret); + } catch (\Exception $e) { + $this->logger->error('[TokenService] Token Exchange: Failed to decrypt oidc client secret'); + } + } + $this->logger->debug('[TokenService] Exchanging the token: ' . $discovery['token_endpoint']); + // more in https://www.keycloak.org/securing-apps/token-exchange + $result = $this->client->post( + $discovery['token_endpoint'], + [ + 'body' => [ + 'client_id' => $oidcProvider->getClientId(), + 'client_secret' => $clientSecret, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange', + 'subject_token' => $loginToken->getAccessToken(), + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:access_token', + // can also be + // urn:ietf:params:oauth:token-type:access_token + // or urn:ietf:params:oauth:token-type:id_token + // this one will get us an access token and refresh token within the response + 'requested_token_type' => 'urn:ietf:params:oauth:token-type:refresh_token', + 'audience' => $targetAudience, + ], + ] + ); + $this->logger->debug('[TokenService] Token exchange request params', [ + 'client_id' => $oidcProvider->getClientId(), + // 'client_secret' => $clientSecret, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange', + // 'subject_token' => $loginToken->getAccessToken(), + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:access_token', + 'requested_token_type' => 'urn:ietf:params:oauth:token-type:refresh_token', + 'audience' => $targetAudience, + ]); + $body = $result->getBody(); + $bodyArray = json_decode(trim($body), true, 512, JSON_THROW_ON_ERROR); + $this->logger->debug('[TokenService] Token exchange success: "' . trim($body) . '"'); + $tokenData = array_merge( + $bodyArray, + ['provider_id' => $loginToken->getProviderId()], + ); + return new Token($tokenData); + } catch (\Exception|\Throwable $e) { + $this->logger->error('[TokenService] Failed to exchange token ', ['exception' => $e]); + throw new TokenExchangeFailedException('Failed to exchange token, error in the exchange request', 0, $e); + } + } +}