Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

## OIDC token handling

This app needs a valid OIDC token to get the central menu content and contact OpenXChange. We get this token from the user_oidc app.
Depending on how user_oidc is configured, we either store and refresh the login token ourselves or rely on user_oidc to do so.

If "Store login tokens" is enabled in user_oidc's admin settings, we know we can use the `OCA\UserOIDC\Event\ExternalTokenRequestedEvent`
to ask user_oidc to provide the login token (or a refreshed one) instead of storing this token ourselves (and refreshing it).

During login the access_token and refresh token are passed by the user_oidc app to the integration_swp app through a dispatched event.
integration_swp will request a fresh token and regularly refresh it with the refresh token that was initially provided by the OpenID Connect login.

Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function __construct(
#[PublicPage]
#[NoCSRFRequired]
#[UseSession]
public function index() {
public function index(): JSONResponse {
/** @var Token $token */
$token = \OC::$server->get(TokenService::class)->getToken(true);
if ($token === null) {
Expand Down
2 changes: 1 addition & 1 deletion lib/Listener/PublicShareTemplateLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use OCP\Util;

/**
* @implements IEventListener<Event>
* @implements IEventListener<BeforeTemplateRenderedEvent>
* Helper class to extend the "publicshare" template from the server.
*/
class PublicShareTemplateLoader implements IEventListener {
Expand Down
36 changes: 1 addition & 35 deletions lib/Listener/TokenObtainedEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,18 @@
namespace OCA\Swp\Listener;

use OCA\Swp\AppInfo\Application;
//use OCA\Swp\Service\OxMailService;
use OCA\Swp\Service\TokenService;
use OCA\UserOIDC\Event\TokenObtainedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
//use OCP\Http\Client\IClientService;
use Psr\Log\LoggerInterface;

/**
* @implements IEventListener<Event>
* @implements IEventListener<TokenObtainedEvent>
*/
class TokenObtainedEventListener implements IEventListener {

public function __construct(
// private IClientService $clientService,
// private OxMailService $mailService,
private LoggerInterface $logger,
private TokenService $tokenService,
) {
Expand All @@ -39,39 +35,9 @@ public function handle(Event $event): void {

$token = $event->getToken();
$provider = $event->getProvider();
$discovery = $event->getDiscovery();

//$refreshToken = $token['refresh_token'] ?? null;

//if (!$refreshToken) {
// $this->logger->debug('handle TokenObtainedEvent NO REFRESH TOKEN', ['app' => Application::APP_ID]);
// return;
//}

//$client = $this->clientService->newClient();
//$this->logger->debug('TokenObtainedEventListener TOKEN REQUEST to ' . $discovery['token_endpoint'] . ' with refresh token=' . $refreshToken . ' and client id=' . $provider->getClientId(), ['app' => Application::APP_ID]);
//$result = $client->post(
// $discovery['token_endpoint'],
// [
// 'body' => [
// 'client_id' => $provider->getClientId(),
// 'client_secret' => $provider->getClientSecret(),
// 'grant_type' => 'refresh_token',
// 'refresh_token' => $refreshToken,
// // TODO check if we need a different scope for this
// 'scope' => $provider->getScope(),
// ],
// ]
//);
//$this->logger->debug('refresh request STATUS CODE:' . $result->getStatusCode(), ['app' => Application::APP_ID]);

//$tokenData = json_decode($result->getBody(), true);

$tokenData = $token;
$this->logger->debug('Storing the token: ' . json_encode($tokenData), ['app' => Application::APP_ID]);
$this->tokenService->storeToken(array_merge($tokenData, ['provider_id' => $provider->getId()]));

// $this->mailService->resetCache();
// $this->mailService->fetchUnreadCounter();
}
}
5 changes: 5 additions & 0 deletions lib/Model/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public function getExpiresIn(): int {
return $this->expiresIn;
}

public function getExpiresInFromNow(): int {
$expiresAt = $this->createdAt + $this->expiresIn;
return $expiresAt - time();
}

public function getRefreshToken(): string {
return $this->refreshToken;
}
Expand Down
51 changes: 50 additions & 1 deletion lib/Service/TokenService.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Exceptions\WipeTokenException;
use OCP\Authentication\Token\IToken;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
Expand Down Expand Up @@ -53,8 +55,10 @@ public function __construct(
private LoggerInterface $logger,
private IRequest $request,
private IConfig $config,
private IAppConfig $appConfig,
private ICrypto $crypto,
private IAppManager $appManager,
private IEventDispatcher $eventDispatcher,
) {
$this->client = $clientService->newClient();
$this->cache = $cacheFactory->createDistributed(Application::APP_ID);
Expand Down Expand Up @@ -88,6 +92,10 @@ public function storeToken(array $tokenData): Token {
}

public function getToken(bool $refresh = true): ?Token {
$isUserOidcStoringTokens = $this->appConfig->getValueString(\OCA\UserOIDC\AppInfo\Application::APP_ID, 'store_login_token', '0') === '1';
if ($isUserOidcStoringTokens) {
return $this->getTokenFromUserOidc();
}
$sessionData = $this->session->get(self::SESSION_TOKEN_KEY);
if (!$sessionData) {
return null;
Expand All @@ -101,10 +109,51 @@ public function getToken(bool $refresh = true): ?Token {
if ($refresh && $token->isExpiring()) {
$token = $this->refresh($token);
}
$this->logger->debug('[SWPTokenService] Obtained a token from our own session storage that expires in ' . $token->getExpiresInFromNow());
return $token;
}

public function refresh(Token $token) {
private function getTokenFromUserOidc(): ?Token {
$event = new \OCA\UserOIDC\Event\ExternalTokenRequestedEvent();
try {
$this->eventDispatcher->dispatchTyped($event);
} catch (\OCA\UserOIDC\Exception\GetExternalTokenFailedException $e) {
$this->logger->debug('Failed to get external token: ' . $e->getMessage());
$error = $e->getError();
$errorDescription = $e->getErrorDescription();
if ($error && $errorDescription) {
$this->logger->debug('Token obtention error: ' . $error . ' (' . $errorDescription . ')');
}
}
$userOidcToken = $event->getToken();
if ($userOidcToken === null) {
$this->logger->debug('There was no token found in the session');
return null;
} else {
$this->logger->debug('[SWPTokenService] Obtained a token from user_oidc that expires in ' . $userOidcToken->getExpiresInFromNow());
return new Token([
'id_token' => $userOidcToken->getIdToken(),
'access_token' => $userOidcToken->getAccessToken(),
'expires_in' => $userOidcToken->getExpiresIn(),
'refresh_token' => $userOidcToken->getRefreshToken(),
'created_at' => $userOidcToken->getCreatedAt(),
'provider_id' => $userOidcToken->getProviderId(),
]);
}
}

/**
* Refresh the token we stored ourselves if user_oidc does not store its tokens and we didn't use the events
*
* @param Token $token
* @return Token
* @throws \JsonException
* @throws \OCP\AppFramework\Db\DoesNotExistException
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function refresh(Token $token): Token {
/** @var ProviderMapper $providerMapper */
$providerMapper = \OC::$server->get(ProviderMapper::class);
$oidcProvider = $providerMapper->getProvider($token->getProviderId());
Expand Down
3 changes: 3 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<referencedClass name="Symfony\Component\Console\Output\OutputInterface" />
<referencedClass name="OCA\UserOIDC\Db\Provider" />
<referencedClass name="OC\Authentication\Token\IProvider" />
<referencedClass name="OCA\UserOIDC\Exception\GetExternalTokenFailedException" />
<referencedClass name="OCA\UserOIDC\AppInfo\Application" />
</errorLevel>
</UndefinedClass>
<UndefinedDocblockClass>
Expand All @@ -52,5 +54,6 @@
<stubs>
<file name="tests/stubs/oc_hooks_emitter.php" />
<file name="tests/stubs/oca_events.php" />
<file name="tests/stubs/oca_user_oidc.php" />
</stubs>
</psalm>
16 changes: 0 additions & 16 deletions tests/stubs/oca_events.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,3 @@ public function getScope(): ?string {
}
}
}

namespace OCA\UserOIDC\Event {

use OCP\EventDispatcher\Event;

class TokenObtainedEvent extends Event {
public function getToken(): array {
}

public function getProvider(): \OCA\UserOIDC\Db\Provider {
}

public function getDiscovery(): array {
}
}
}
72 changes: 72 additions & 0 deletions tests/stubs/oca_user_oidc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\UserOIDC\Event {

use OCP\EventDispatcher\Event;

class TokenObtainedEvent extends Event {
public function getToken(): array {
}

public function getProvider(): \OCA\UserOIDC\Db\Provider {
}

public function getDiscovery(): array {
}
}

class ExternalTokenRequestedEvent extends Event {
public function getToken(): ?\OCA\UserOIDC\Model\Token {
}
}
}
namespace OCA\UserOIDC\Model {
class Token {
public function getAccessToken(): string {
}

public function getIdToken(): ?string {
}

public function getExpiresIn(): int {
}

public function getExpiresInFromNow(): int {
}

public function getRefreshExpiresIn(): ?int {
}

public function getRefreshExpiresInFromNow(): int {
}

public function getRefreshToken(): ?string {
}

public function getProviderId(): ?int {
}

public function isExpired(): bool {
}

public function isExpiring(): bool {
}

public function refreshIsExpired(): bool {
}

public function refreshIsExpiring(): bool {
}

public function getCreatedAt() {
}

public function jsonSerialize(): array {
}
}
}