diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 71a8d61c..923ffd56 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -95,11 +95,11 @@ jobs: working-directory: apps/${{ env.APP_NAME }}/tests/ - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@2.12.0 + uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} tools: phpunit - extensions: mbstring, iconv, fileinfo, intl, sqlite, pdo_sqlite, mysql, pdo_mysql, pgsql, pdo_pgsql, + extensions: zip, gd, mbstring, iconv, fileinfo, intl, sqlite, pdo_sqlite, mysql, pdo_mysql, pgsql, pdo_pgsql coverage: none - name: Set up PHPUnit diff --git a/docs/token_exchange.md b/docs/token_exchange.md new file mode 100644 index 00000000..843105f3 --- /dev/null +++ b/docs/token_exchange.md @@ -0,0 +1,51 @@ + +### Token exchange + +If your IdP supports token exchange, user_oidc can exchange the login token against another token. + +Keycloak supports token exchange if its "Preview" mode is enabled. See https://www.keycloak.org/securing-apps/token-exchange . + +:warning: Your IdP need to be configured accordingly. For example, Keycloak requires that token exchange is explicitely +authorized for the target Oidc client. + +The type of token exchange that user_oidc can perform is "Internal token to internal token" +(https://www.keycloak.org/securing-apps/token-exchange#_internal-token-to-internal-token-exchange). +This means you can exchange a token delivered for an audience "A" for a token delivered for an audience "B". +In other words, you can get a token of a different Oidc client than the one you configured in user_oidc. + +In short, you don't need the client ID and client secret of the target audience's client. +Providing a token for the audience "A" (the login token) is enough to obtain a token for the audience "B". + +user_oidc is storing the login token in the user's Nextcloud session and takes care of refreshing it when needed. +When another app wants to exchange the current login token for another one, +it can dispatch the `OCA\UserOIDC\Event\ExchangedTokenRequestedEvent` event. +The exchanged token is immediately stored in the event object itself. + +```php +if (class_exists('OCA\UserOIDC\Event\ExchangedTokenRequestedEvent')) { + $event = new OCA\UserOIDC\Event\ExchangedTokenRequestedEvent('my_target_audience'); + try { + $this->eventDispatcher->dispatchTyped($event); + } catch (OCA\UserOIDC\Exception\TokenExchangeFailedException $e) { + $this->logger->debug('Failed to exchange token: ' . $e->getMessage()); + $error = $e->getError(); + $errorDescription = $e->getErrorDescription(); + if ($error && $errorDescription) { + $this->logger->debug('Token exchange error response from the IdP: ' . $error . ' (' . $errorDescription . ')'); + } + } + $token = $event->getToken(); + if ($token === null) { + $this->logger->debug('ExchangedTokenRequestedEvent event has not been caught by user_oidc'); + } else { + $this->logger->debug('Obtained a token that expires in ' . $token->getExpiresInFromNow()); + // use the token + $accessToken = $token->getAccessToken(); + } +} else { + $this->logger->debug('The user_oidc app is not installed/available'); +} +``` diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 139a9083..736b605e 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 75c56fd3..22b64feb 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); } @@ -113,7 +115,7 @@ private function getRedirectResponse(?string $redirectUrl = null): RedirectRespo // or even: if (preg_match('/https?:\/\//', $redirectUrl) === 1) return new RedirectResponse('/'); return new RedirectResponse( $redirectUrl === null - ? null + ? $this->urlGenerator->getBaseUrl() : join('?', array_filter(parse_url($redirectUrl), fn ($k) => in_array($k, ['path', 'query']), ARRAY_FILTER_USE_KEY)) ); } @@ -508,6 +510,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..7db7aaec --- /dev/null +++ b/lib/Event/ExchangedTokenRequestedEvent.php @@ -0,0 +1,42 @@ +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..e1d0b034 --- /dev/null +++ b/lib/Exception/TokenExchangeFailedException.php @@ -0,0 +1,32 @@ +error; + } + + public function getErrorDescription(): ?string { + return $this->errorDescription; + } +} diff --git a/lib/Listener/ExchangedTokenRequestedListener.php b/lib/Listener/ExchangedTokenRequestedListener.php new file mode 100644 index 00000000..1904da55 --- /dev/null +++ b/lib/Listener/ExchangedTokenRequestedListener.php @@ -0,0 +1,44 @@ + + */ +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..4bc5422d --- /dev/null +++ b/lib/Model/Token.php @@ -0,0 +1,98 @@ +idToken = $tokenData['id_token'] ?? null; + $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/DiscoveryService.php b/lib/Service/DiscoveryService.php index 91108546..d1be8eb1 100644 --- a/lib/Service/DiscoveryService.php +++ b/lib/Service/DiscoveryService.php @@ -46,7 +46,7 @@ public function __construct( } public function obtainDiscovery(Provider $provider): array { - $cacheKey = 'discovery-' . $provider->getId(); + $cacheKey = 'discovery-' . $provider->getDiscoveryEndpoint(); $cachedDiscovery = $this->cache->get($cacheKey); if ($cachedDiscovery === null) { $url = $provider->getDiscoveryEndpoint(); diff --git a/lib/Service/TokenService.php b/lib/Service/TokenService.php new file mode 100644 index 00000000..de886531 --- /dev/null +++ b/lib/Service/TokenService.php @@ -0,0 +1,299 @@ +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 (ClientException|ServerException $e) { + $response = $e->getResponse(); + $body = (string)$response->getBody(); + $this->logger->error('[TokenService] Failed to exchange token, client/server error in the exchange request', ['response_body' => $body, 'exception' => $e]); + + $parsedBody = json_decode(trim($body), true); + if (is_array($parsedBody) && isset($parsedBody['error'], $parsedBody['error_description'])) { + throw new TokenExchangeFailedException( + 'Failed to exchange token, client/server error in the exchange request: ' . $body, + 0, + $e, + $parsedBody['error'], + $parsedBody['error_description'], + ); + } else { + throw new TokenExchangeFailedException( + 'Failed to exchange token, client/server error in the exchange request: ' . $body, + 0, + $e, + ); + } + } 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); + } + } +} diff --git a/package-lock.json b/package-lock.json index 088de0fe..a0d302ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,9 @@ "@nextcloud/dialogs": "^5.3.7", "@nextcloud/initial-state": "^2.2.0", "@nextcloud/logger": "^2.7.0", - "@nextcloud/password-confirmation": "^5.1.1", + "@nextcloud/password-confirmation": "^5.3.0", "@nextcloud/router": "^3.0.1", - "@nextcloud/vue": "^8.19.0", + "@nextcloud/vue": "^8.21.0", "jstz": "^2.1.1", "vue": "^2.7.14", "vue-material-design-icons": "^5.3.1" @@ -2732,11 +2732,11 @@ } }, "node_modules/@nextcloud/files": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.8.0.tgz", - "integrity": "sha512-5oi61suf2nDcXPTA4BSxl7EomJBCWrmc6ZGaokaj+jREOsSVlS+nR3ID/6eMqZSsqODpAARK56djyUPmiHOLWQ==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.10.0.tgz", + "integrity": "sha512-VvucXNM+Ci/Ej1nK1UAboliiPpAY8az6cDDMoBWxgtfKRL7Q9I0aN2/nl4V9j2JaCm6E4TVWnKXlYDySMPNQKQ==", "dependencies": { - "@nextcloud/auth": "^2.3.0", + "@nextcloud/auth": "^2.4.0", "@nextcloud/capabilities": "^1.2.0", "@nextcloud/l10n": "^3.1.0", "@nextcloud/logger": "^3.0.2", @@ -2744,10 +2744,10 @@ "@nextcloud/router": "^3.0.1", "@nextcloud/sharing": "^0.2.3", "cancelable-promise": "^4.3.1", - "is-svg": "^5.0.1", + "is-svg": "^5.1.0", "typedoc-plugin-missing-exports": "^3.0.0", "typescript-event-target": "^1.1.1", - "webdav": "^5.7.0" + "webdav": "^5.7.1" }, "engines": { "node": "^20.0.0", @@ -2807,12 +2807,13 @@ } }, "node_modules/@nextcloud/password-confirmation": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@nextcloud/password-confirmation/-/password-confirmation-5.1.1.tgz", - "integrity": "sha512-UlQcjVe/fr/JaJ6TWaRM+yBLIEZRU6RWMy0JoExcA6UVJs2HJrRIyVMuiCLuIYlH23ReJH+z7zFI3+V7vdeJ1Q==", - "license": "MIT", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/password-confirmation/-/password-confirmation-5.3.0.tgz", + "integrity": "sha512-i5W0ElClgnN8W186F9QigGDb1jsk/02n3cqQfXDLGbagisotF6TYhqFrxj6aYprzELZJgbjaMo3Fc7UDvpP3Ow==", "dependencies": { + "@nextcloud/auth": "^2.4.0", "@nextcloud/axios": "^2.5.0", + "@nextcloud/dialogs": "^6.0.1", "@nextcloud/l10n": "^3.1.0", "@nextcloud/router": "^3.0.1" }, @@ -2825,6 +2826,121 @@ "vue": "^2.7.16" } }, + "node_modules/@nextcloud/password-confirmation/node_modules/@nextcloud/dialogs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-6.0.1.tgz", + "integrity": "sha512-TlzNUy0eFPIjnop2Wfom45xJdUjLOoUwd/E8V/6ehh+PifrgIWGy6jqBYMRoQfUASc/LKtSYUrCN3yDgVrK8Tw==", + "dependencies": { + "@mdi/js": "^7.4.47", + "@nextcloud/auth": "^2.4.0", + "@nextcloud/axios": "^2.5.1", + "@nextcloud/event-bus": "^3.3.1", + "@nextcloud/files": "^3.9.0", + "@nextcloud/initial-state": "^2.2.0", + "@nextcloud/l10n": "^3.1.0", + "@nextcloud/router": "^3.0.1", + "@nextcloud/sharing": "^0.2.3", + "@nextcloud/typings": "^1.9.1", + "@types/toastify-js": "^1.12.3", + "@vueuse/core": "^11.2.0", + "cancelable-promise": "^4.3.1", + "p-queue": "^8.0.1", + "toastify-js": "^1.12.0", + "vue-frag": "^1.4.3", + "webdav": "^5.7.1" + }, + "engines": { + "node": "^20.0.0", + "npm": "^10.0.0" + }, + "peerDependencies": { + "@nextcloud/vue": "^8.16.0", + "vue": "^2.7.16" + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vueuse/core": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.3.0.tgz", + "integrity": "sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "11.3.0", + "@vueuse/shared": "11.3.0", + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vueuse/metadata": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.3.0.tgz", + "integrity": "sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vueuse/shared": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==", + "dependencies": { + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@nextcloud/paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-2.2.1.tgz", @@ -2898,9 +3014,9 @@ } }, "node_modules/@nextcloud/vue": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.19.0.tgz", - "integrity": "sha512-mEawbIueee5fSGZreJV+/8h80SRriRTuib1UO9UWWEgqWvZQp0i99xXnIQj+UMw9AugxznJWd5R0ZOmZkN7p5w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.21.0.tgz", + "integrity": "sha512-at06uh2JJkn8dV3Yzyoag2z1g6omad8MZ8yKWE+9ZAGP+kaysbnI5q3lB7KXu8SVRtX3Rex8/oal0jgsbb6Spg==", "dependencies": { "@floating-ui/dom": "^1.1.0", "@linusborg/vue-simple-portal": "^0.1.5", @@ -2919,7 +3035,7 @@ "@vueuse/components": "^11.0.0", "@vueuse/core": "^11.0.0", "clone": "^2.1.2", - "debounce": "2.1.1", + "debounce": "^2.2.0", "dompurify": "^3.0.5", "emoji-mart-vue-fast": "^15.0.1", "escape-html": "^1.0.3", @@ -2928,6 +3044,7 @@ "linkify-string": "^4.0.0", "md5": "^2.3.0", "rehype-external-links": "^3.0.0", + "rehype-highlight": "^7.0.1", "rehype-react": "^7.1.2", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", @@ -6143,9 +6260,9 @@ "dev": true }, "node_modules/debounce": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.1.1.tgz", - "integrity": "sha512-+xRWxgel9LgTC4PwKlm7TJUK6B6qsEK77NaiNvXmeQ7Y3e6OVVsBC4a9BSptS/mAYceyAz37Oa8JTTuPRft7uQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", + "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", "engines": { "node": ">=18" }, @@ -8699,6 +8816,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", @@ -8717,6 +8849,14 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz", + "integrity": "sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -10151,6 +10291,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.2.0.tgz", + "integrity": "sha512-8Me8xHTCBYEXwcJIPcurnXTeERl3plwb4207v6KPye48kX/oaYDiwXy+OCm3M/pyAPUrkMhalKsbYPm24f/UDg==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -11736,6 +11890,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.0.1.tgz", + "integrity": "sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/p-retry": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.0.tgz", @@ -11754,6 +11928,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.3.tgz", + "integrity": "sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -12720,6 +12905,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-highlight": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.1.tgz", + "integrity": "sha512-dB/vVGFsbm7xPglqnYbg0ABg6rAuIWKycTvuXaOO27SgLoOFNoTlniTBtAxp3n5ZyMioW1a3KwiNqgjkb6Skjg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-react": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/rehype-react/-/rehype-react-7.2.0.tgz", @@ -15188,6 +15389,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", diff --git a/package.json b/package.json index 5039ad8e..019aa019 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,9 @@ "@nextcloud/dialogs": "^5.3.7", "@nextcloud/initial-state": "^2.2.0", "@nextcloud/logger": "^2.7.0", - "@nextcloud/password-confirmation": "^5.1.1", + "@nextcloud/password-confirmation": "^5.3.0", "@nextcloud/router": "^3.0.1", - "@nextcloud/vue": "^8.19.0", + "@nextcloud/vue": "^8.21.0", "jstz": "^2.1.1", "vue": "^2.7.14", "vue-material-design-icons": "^5.3.1"