Skip to content

Commit

Permalink
Merge branch 'main' into fix-redirect-url
Browse files Browse the repository at this point in the history
  • Loading branch information
julien-nc authored Dec 3, 2024
2 parents 1c5477d + 0c2f96a commit bbf13e6
Show file tree
Hide file tree
Showing 12 changed files with 824 additions and 25 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions docs/token_exchange.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
### 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');
}
```
9 changes: 9 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -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');
Expand Down
12 changes: 11 additions & 1 deletion lib/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +78,7 @@ public function __construct(
private IL10N $l10n,
private LoggerInterface $logger,
private ICrypto $crypto,
private TokenService $tokenService,
) {
parent::__construct($request, $config);
}
Expand Down Expand Up @@ -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))
);
}
Expand Down Expand Up @@ -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()));

Expand Down
42 changes: 42 additions & 0 deletions lib/Event/ExchangedTokenRequestedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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;
}
}
32 changes: 32 additions & 0 deletions lib/Exception/TokenExchangeFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\UserOIDC\Exception;

use Exception;

class TokenExchangeFailedException extends Exception {

public function __construct(
$message = '',
$code = 0,
$previous = null,
private ?string $error = null,
private ?string $errorDescription = null,
) {
parent::__construct($message, $code, $previous);
}

public function getError(): ?string {
return $this->error;
}

public function getErrorDescription(): ?string {
return $this->errorDescription;
}
}
44 changes: 44 additions & 0 deletions lib/Listener/ExchangedTokenRequestedListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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<ExchangedTokenRequestedEvent|Event>
*/
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);
}
}
98 changes: 98 additions & 0 deletions lib/Model/Token.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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'] ?? 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,
];
}
}
2 changes: 1 addition & 1 deletion lib/Service/DiscoveryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading

0 comments on commit bbf13e6

Please sign in to comment.