Skip to content

Commit

Permalink
Merge branch 'main' into nmc/408-fix-backchannel-logout
Browse files Browse the repository at this point in the history
  • Loading branch information
memurats authored Feb 4, 2025
2 parents 65be061 + afa98a5 commit b255fb5
Show file tree
Hide file tree
Showing 19 changed files with 280 additions and 56 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
- php-versions: 8.1
databases: mysql
server-versions: stable30
- php-versions: 8.1
databases: mysql
server-versions: stable31
- php-versions: 8.1
databases: mysql
server-versions: master
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ jobs:
matrix:
php-versions: ['8.0', '8.1', '8.2']
databases: ['mysql']
server-versions: ['stable26', 'stable27', 'stable28', 'stable29', 'stable30', 'master']
server-versions: ['stable26', 'stable27', 'stable28', 'stable29', 'stable30', 'stable31', 'master']
exclude:
- php-versions: 8.0
server-versions: master
- php-versions: 8.0
server-versions: stable31
- php-versions: 8.0
server-versions: stable30
include:
Expand All @@ -40,6 +42,15 @@ jobs:
databases: mysql
server-versions: stable30
- php-versions: 8.3
databases: mysql
server-versions: stable31
- php-versions: 8.4
databases: mysql
server-versions: stable31
- php-versions: 8.3
databases: mysql
server-versions: master
- php-versions: 8.4
databases: mysql
server-versions: master

Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 6.2.1 – 2025-01-23

### Fixed

- Fix crash when storing a token without refresh_expires_in or refresh_token @julien-nc [#1025](https://github.com/nextcloud/user_oidc/pull/1025)
- Disable token exchange mechanism by default @julien-nc [#1025](https://github.com/nextcloud/user_oidc/pull/1025)

## 6.2.0 – 2025-01-20

### Added

- Support for Global Scale (globalsiteselector app) @julien-nc [#1011](https://github.com/nextcloud/user_oidc/pull/1011)
- Add whitelist regular expression for group provisioning @bergerar [#884](https://github.com/nextcloud/user_oidc/pull/884)
- Optionally restrict login to users matching a certain group @bergerar [#884](https://github.com/nextcloud/user_oidc/pull/884)
- Token exchange mechanism for other apps @julien-nc [#974](https://github.com/nextcloud/user_oidc/pull/974)
- Password confirmation in admin settings @janepie [#991](https://github.com/nextcloud/user_oidc/pull/991)
- Add option to configure bearer provisioning via occ @janepie [#1003](https://github.com/nextcloud/user_oidc/pull/1003)
- Add config value to make the email match optional when searching for a user or a display name @julien-nc [#1014](https://github.com/nextcloud/user_oidc/pull/1014)

### Changed

- Make the app Reuse compliant @AndyScherzinger [#975](https://github.com/nextcloud/user_oidc/pull/975)
- Add support for comma-separated groups in group mapping attribute @julien-nc [#1006](https://github.com/nextcloud/user_oidc/pull/1006)

### Fixed

- Update cache when discovery endpoint is changed @janepie [#1002](https://github.com/nextcloud/user_oidc/pull/1002)
- Set fallback redirect URL for login if already logged in @janepie [#1001](https://github.com/nextcloud/user_oidc/pull/1001)
- Fix redirect URI when Nextcloud is accessed at a sub path @bdovaz [#990](https://github.com/nextcloud/user_oidc/pull/990)
- Handle redirect URL containing a ':' @artonge [#1008](https://github.com/nextcloud/user_oidc/pull/1008)
- Avoid slow queries in scenarios where we do not need a search @juliusknorr [#1019](https://github.com/nextcloud/user_oidc/pull/1019)
- Adjust provisioning service to correctly update the display name on login @julien-nc [#979](https://github.com/nextcloud/user_oidc/pull/979)

## 6.1.2 – 2024-10-30

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<name>OpenID Connect user backend</name>
<summary>Use an OpenID Connect backend to login to your Nextcloud</summary>
<description>Allows flexible configuration of an OIDC server as Nextcloud login user backend.</description>
<version>6.1.2</version>
<version>6.3.0-dev.0</version>
<licence>agpl</licence>
<author>Roeland Jago Douma</author>
<author>Julius Härtl</author>
Expand All @@ -23,7 +23,7 @@
<bugs>https://github.com/nextcloud/user_oidc/issues</bugs>
<repository>https://github.com/nextcloud/user_oidc</repository>
<dependencies>
<nextcloud min-version="26" max-version="31"/>
<nextcloud min-version="26" max-version="32"/>
</dependencies>
<settings>
<admin>OCA\UserOIDC\Settings\AdminSettings</admin>
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions docs/token_exchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

If your IdP supports token exchange, user_oidc can exchange the login token against another token.

:warning: The token exchange feature is disabled by default. You can enable it in `config.php`:
``` php
'user_oidc' => [
'token_exchange' => true,
],
```

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
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function createUser(int $providerId, string $userId, ?string $displayName
*/
public function deleteUser(string $userId): DataResponse {
$user = $this->userManager->get($userId);
if (is_null($user) || $user->getBackendClassName() !== 'user_oidc') {
if (is_null($user) || $user->getBackendClassName() !== Application::APP_ID) {
return new DataResponse(['message' => 'User not found'], Http::STATUS_NOT_FOUND);
}

Expand Down
58 changes: 45 additions & 13 deletions lib/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
use OCA\UserOIDC\Service\ProviderService;
use OCA\UserOIDC\Service\ProvisioningService;
use OCA\UserOIDC\Service\TokenService;
use OCA\UserOIDC\User\Backend;
use OCA\UserOIDC\Vendor\Firebase\JWT\JWT;
use OCA\UserOIDC\Vendor\Firebase\JWT\Key;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Http;
Expand All @@ -47,6 +49,8 @@
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Session\Exceptions\SessionNotAvailableException;
use OCP\User\Events\BeforeUserLoggedInEvent;
use OCP\User\Events\UserLoggedInEvent;
use Psr\Log\LoggerInterface;

class LoginController extends BaseOidcController {
Expand Down Expand Up @@ -480,18 +484,22 @@ public function code(string $state = '', string $code = '', string $scope = '',
}

$autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']);
$softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']);

$shouldDoUserLookup = !$autoProvisionAllowed || ($softAutoProvisionAllowed && !$this->provisioningService->hasOidcUserProvisitioned($userId));
if ($shouldDoUserLookup && $this->ldapService->isLDAPEnabled()) {
// in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results
// so new users will be directly available even if they were not synced before this login attempt
$this->userManager->search($userId, 1, 0);
$this->ldapService->syncUser($userId);
}

// in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results
// so new users will be directly available even if they were not synced before this login attempt
$this->userManager->search($userId);
$this->ldapService->syncUser($userId);
$userFromOtherBackend = $this->userManager->get($userId);
if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) {
$userFromOtherBackend = null;
}

if ($autoProvisionAllowed) {
$softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']);
if (!$softAutoProvisionAllowed && $userFromOtherBackend !== null) {
// if soft auto-provisioning is disabled,
// we refuse login for a user that already exists in another backend
Expand Down Expand Up @@ -519,19 +527,26 @@ public function code(string $state = '', string $code = '', string $scope = '',
$this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']);
$this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID());
$this->userSession->createRememberMeToken($user);
// TODO server should/could be refactored so we don't need to manually create the user session and dispatch the login-related events
$this->eventDispatcher->dispatchTyped(new BeforeUserLoggedInEvent($user->getUID(), null, \OC::$server->get(Backend::class)));
$this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $user->getUID(), null, false));
}

// remove code login session values
$this->session->remove(self::STATE);
$this->session->remove(self::NONCE);

// 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');
// $tokenExchangeEnabled = (isset($oidcSystemConfig['token_exchange']) && $oidcSystemConfig['token_exchange'] === true);
// if ($tokenExchangeEnabled) {
// 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 Expand Up @@ -569,6 +584,7 @@ public function code(string $state = '', string $code = '', string $scope = '',
/**
* Endpoint called by NC to logout in the IdP before killing the current session
*
* @PublicPage
* @NoAdminRequired
* @NoCSRFRequired
* @UseSession
Expand All @@ -584,7 +600,23 @@ public function singleLogoutService() {
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
$targetUrl = $this->urlGenerator->getAbsoluteURL('/');
if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) {
$providerId = $this->session->get(self::PROVIDERID);
$isFromGS = ($this->config->getSystemValueBool('gs.enabled', false)
&& $this->config->getSystemValueString('gss.mode', '') === 'master');
if ($isFromGS) {
// Request is from master GlobalScale: we get the provider ID from the JWT token provided by the slave
$jwt = $this->request->getParam('jwt', '');

try {
$key = $this->config->getSystemValueString('gss.jwt.key', '');
$decoded = (array)JWT::decode($jwt, new Key($key, 'HS256'));

$providerId = $decoded['oidcProviderId'] ?? null;
} catch (\Exception $e) {
$this->logger->debug('Failed to get the logout provider ID in the request from GSS', ['exception' => $e]);
}
} else {
$providerId = $this->session->get(self::PROVIDERID);
}
if ($providerId) {
try {
$provider = $this->providerMapper->getProvider((int)$providerId);
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/OcsApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public function createUser(int $providerId, string $userId, ?string $displayName
*/
public function deleteUser(string $userId): DataResponse {
$user = $this->userManager->get($userId);
if (is_null($user) || $user->getBackendClassName() !== 'user_oidc') {
if (is_null($user) || $user->getBackendClassName() !== Application::APP_ID) {
return new DataResponse(['message' => 'User not found'], Http::STATUS_NOT_FOUND);
}

Expand Down
2 changes: 1 addition & 1 deletion lib/Event/ExchangedTokenRequestedEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use OCP\EventDispatcher\Event;

/**
* This event is emitted with by other apps which need an exchanged token for another audience (another client ID)
* This event is emitted by other apps which need an exchanged token for another audience (another client ID)
*/
class ExchangedTokenRequestedEvent extends Event {

Expand Down
25 changes: 19 additions & 6 deletions lib/Model/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ class Token implements JsonSerializable {
private ?string $idToken;
private string $accessToken;
private int $expiresIn;
private int $refreshExpiresIn;
private string $refreshToken;
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->refreshExpiresIn = $tokenData['refresh_expires_in'] ?? null;
$this->refreshToken = $tokenData['refresh_token'] ?? null;
$this->createdAt = $tokenData['created_at'] ?? time();
$this->providerId = $tokenData['provider_id'] ?? null;
}
Expand All @@ -47,16 +47,21 @@ public function getExpiresInFromNow(): int {
return $expiresAt - time();
}

public function getRefreshExpiresIn(): int {
public function getRefreshExpiresIn(): ?int {
return $this->refreshExpiresIn;
}

public function getRefreshExpiresInFromNow(): int {
// if there is no refresh_expires_in, we assume the refresh token never expires
// so we don't need getRefreshExpiresInFromNow
if ($this->refreshExpiresIn === null) {
return 0;
}
$refreshExpiresAt = $this->createdAt + $this->refreshExpiresIn;
return $refreshExpiresAt - time();
}

public function getRefreshToken(): string {
public function getRefreshToken(): ?string {
return $this->refreshToken;
}

Expand All @@ -73,10 +78,18 @@ public function isExpiring(): bool {
}

public function refreshIsExpired(): bool {
// if there is no refresh_expires_in, we assume the refresh token never expires
if ($this->refreshExpiresIn === null) {
return false;
}
return time() > ($this->createdAt + $this->refreshExpiresIn);
}

public function refreshIsExpiring(): bool {
// if there is no refresh_expires_in, we assume the refresh token never expires
if ($this->refreshExpiresIn === null) {
return false;
}
return time() > ($this->createdAt + (int)($this->refreshExpiresIn / 2));
}

Expand Down
10 changes: 10 additions & 0 deletions lib/Service/LdapService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace OCA\UserOIDC\Service;

use OCP\App\IAppManager;
use OCP\AppFramework\QueryException;
use OCP\IUser;
use Psr\Log\LoggerInterface;
Expand All @@ -16,16 +17,25 @@ class LdapService {

public function __construct(
private LoggerInterface $logger,
private IAppManager $appManager,
) {
}

public function isLDAPEnabled(): bool {
return $this->appManager->isEnabledForUser('user_ldap');
}

/**
* @param IUser $user
* @return bool
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function isLdapDeletedUser(IUser $user): bool {
if ($this->isLDAPEnabled()) {
return false;
}

$className = $user->getBackendClassName();
if ($className !== 'LDAP') {
return false;
Expand Down
Loading

0 comments on commit b255fb5

Please sign in to comment.