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
438 changes: 438 additions & 0 deletions doc/oidc-xoauth2.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use OCA\Mail\Dashboard\ImportantMailWidget;
use OCA\Mail\Dashboard\UnreadMailWidget;
use OCA\Mail\Events\BeforeImapClientCreated;
use OCA\Mail\Events\BeforeSmtpClientCreated;
use OCA\Mail\Events\DraftMessageCreatedEvent;
use OCA\Mail\Events\DraftSavedEvent;
use OCA\Mail\Events\MailboxesSynchronizedEvent;
Expand Down Expand Up @@ -130,6 +131,7 @@ public function register(IRegistrationContext $context): void {

$context->registerEventListener(AddMissingIndicesEvent::class, OptionalIndicesListener::class);
$context->registerEventListener(BeforeImapClientCreated::class, OauthTokenRefreshListener::class);
$context->registerEventListener(BeforeSmtpClientCreated::class, OauthTokenRefreshListener::class);
$context->registerEventListener(DraftSavedEvent::class, DeleteDraftListener::class);
$context->registerEventListener(DraftMessageCreatedEvent::class, DeleteDraftListener::class);
$context->registerEventListener(OutboxMessageCreatedEvent::class, DeleteDraftListener::class);
Expand Down
4 changes: 2 additions & 2 deletions lib/Db/MailAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@
* @method int getSignatureMode()
* @method void setSignatureMode(int $signatureMode)
* @method string|null getOauthAccessToken()
* @method void setOauthAccessToken(string $token)
* @method void setOauthAccessToken(?string $token)
* @method string|null getOauthRefreshToken()
* @method void setOauthRefreshToken(string $token)
* @method void setOauthRefreshToken(?string $token)
* @method int|null getOauthTokenTtl()
* @method void setOauthTokenTtl(int|null $ttl)
* @method int|null getSmimeCertificateId()
Expand Down
5 changes: 5 additions & 0 deletions lib/Db/Provisioning.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
* @method void setMasterPasswordEnabled(bool $masterPasswordEnabled)
* @method string|null getMasterPassword()
* @method void setMasterPassword(string $masterPassword)
* @method bool|null getOidcEnabled()
* @method void setOidcEnabled(bool $oidcEnabled)
* @method bool|null getSieveEnabled()
* @method void setSieveEnabled(bool $sieveEnabled)
* @method string|null getSieveHost()
Expand Down Expand Up @@ -72,6 +74,7 @@ class Provisioning extends Entity implements JsonSerializable {
protected $smtpSslMode;
protected $masterPasswordEnabled;
protected $masterPassword;
protected $oidcEnabled;
protected $sieveEnabled;
protected $sieveUser;
protected $sieveHost;
Expand All @@ -86,6 +89,7 @@ public function __construct() {
$this->addType('smtpPort', 'integer');
$this->addType('masterPasswordEnabled', 'boolean');
$this->addType('masterPassword', 'string');
$this->addType('oidcEnabled', 'boolean');
$this->addType('sieveEnabled', 'boolean');
$this->addType('sievePort', 'integer');
$this->addType('ldapAliasesProvisioning', 'boolean');
Expand All @@ -108,6 +112,7 @@ public function jsonSerialize() {
'smtpSslMode' => $this->getSmtpSslMode(),
'masterPasswordEnabled' => $this->getMasterPasswordEnabled(),
'masterPassword' => !empty($this->getMasterPassword()) ? self::MASTER_PASSWORD_PLACEHOLDER : null,
'oidcEnabled' => $this->getOidcEnabled(),
'sieveEnabled' => $this->getSieveEnabled(),
'sieveUser' => $this->getSieveUser(),
'sieveHost' => $this->getSieveHost(),
Expand Down
2 changes: 2 additions & 0 deletions lib/Db/ProvisioningMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ public function validate(array $data): Provisioning {
$provisioning->setMasterPassword($data['masterPassword']);
}

$provisioning->setOidcEnabled((bool)($data['oidcEnabled'] ?? false));

$provisioning->setSieveEnabled((bool)$data['sieveEnabled']);
$provisioning->setSieveHost($data['sieveHost'] ?? '');
$provisioning->setSieveUser($data['sieveUser'] ?? '');
Expand Down
25 changes: 25 additions & 0 deletions lib/Events/BeforeSmtpClientCreated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Events;

use OCA\Mail\Account;
use OCP\EventDispatcher\Event;

class BeforeSmtpClientCreated extends Event {
public function __construct(
private Account $account,
) {
parent::__construct();
}

public function getAccount(): Account {
return $this->account;
}
}
2 changes: 2 additions & 0 deletions lib/Http/Middleware/ProvisioningMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public function beforeController($controller, $methodName) {
}
try {
$this->provisioningManager->provisionSingleUser($configs, $user);
// Keep the mirrored OIDC token fresh (no-op for password-based configs)
$this->provisioningManager->updateOidcToken($user, $configs);
$password = $this->credentialStore->getLoginCredentials()->getPassword();
$this->provisioningManager->updatePassword(
$user,
Expand Down
270 changes: 270 additions & 0 deletions lib/Integration/OidcIntegration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Integration;

use Exception;
use OCA\Mail\Account;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Http\Client\IClientService;
use OCP\IUserSession;
use OCP\Security\ICrypto;
use Psr\Log\LoggerInterface;
use function json_decode;

/**
* Authenticate provisioned accounts against the configured IMAP/SMTP servers with the
* logged-in user's OIDC access token (XOAUTH2) instead of a stored password.
*
* Token life cycle:
* - Interactive requests: the current (auto-refreshed) login token is read from the
* user_oidc session via ExternalTokenRequestedEvent and mirrored into the account row
* (ProvisioningMiddleware and refresh()).
* - Background jobs: refresh() renews the mirrored token at the IdP's token endpoint
* using the stored refresh token, exactly like the Google/Microsoft integrations.
*
* The IdP token endpoint and client credentials are read from the user_oidc provider
* configuration; this integration is inert when user_oidc is not installed.
*/
class OidcIntegration {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To manage the complexity of this change I'd favor getting individual OIDC support in first (and close #12491), and then build OIDC provisioning on top.

@LeahAuroraV LeahAuroraV Jul 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick review!

So basically create an oauth client within the mail app in which users can choose OpenID as option when adding a mailbox, with a well-known discovery url (or the individual endpoints that would contain), client id and client secret, and then have the open id authentication open in a new tab?

Then the provisioning on top would be defining those urls, client id and secret in the admin settings, rather than using the the user_oidc app.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or would the clients always be global settings?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have XOAUTH2 support for Google and Microsoft. Admins set the client id and secret. When a user sets up an account we detect if oauth should be used based on the IMAP server.

For OIDC I could imagine the same. Admin sets client id, secret and server hostname. When a user then creates a new account for that hostname it will open the auth popup for that server.

/** @var array<int, string> */
private array $tokenEndpoints = [];

public function __construct(
private ITimeFactory $timeFactory,
private ICrypto $crypto,
private IClientService $clientService,
private IEventDispatcher $eventDispatcher,
private IAppManager $appManager,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
}

/**
* Whether accounts can be provisioned with OIDC token authentication, i.e. the
* user_oidc app is installed, enabled and exposes the token API this integration
* builds on.
*/
public function isAvailable(): bool {
return $this->appManager->isEnabledForAnyone('user_oidc')
&& class_exists(\OCA\UserOIDC\Event\ExternalTokenRequestedEvent::class);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\OCA* is the private API of another app. This is not supposed to be used by other apps to avoid inter app dependencies. The clean way is going through a OCP public API that lives in Nextcloud server and has a strong stability guarantee.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/nextcloud/user_oidc/blob/main/lib/Event/ExternalTokenRequestedEvent.php does specifically mention in its docblock that it is meant for external apps.

}

public function isOidcAccount(Account $account): bool {
// Only provisioned accounts use the OIDC token. Google/Microsoft xoauth2 accounts
// are linked by the user and never carry a provisioning id.
return $account->getMailAccount()->getProvisioningId() !== null
&& $account->getMailAccount()->getAuthMethod() === 'xoauth2';
}

/**
* Mirror the current user_oidc session token into the account row if it is fresher
* than what is stored. No-op without an active user session holding a token.
*/
public function updateFromSession(Account $account): Account {
if (!class_exists(\OCA\UserOIDC\Event\ExternalTokenRequestedEvent::class)) {
return $account;
}

// The session token belongs to the logged-in user. Never mirror it into someone
// else's account, e.g. when acting on a delegated account.
$sessionUser = $this->userSession->getUser();
if ($sessionUser === null || $sessionUser->getUID() !== $account->getMailAccount()->getUserId()) {
return $account;
}

try {
$event = new \OCA\UserOIDC\Event\ExternalTokenRequestedEvent();
$this->eventDispatcher->dispatchTyped($event);
$token = $event->getToken();
} catch (\Throwable $e) {
$this->logger->debug('Could not get OIDC token from user_oidc session: ' . $e->getMessage(), [
'exception' => $e,
]);
return $account;
}

if ($token === null || $token->isExpired()) {
return $account;
}

$expiresAt = $this->timeFactory->getTime() + $token->getExpiresInFromNow();
$storedTtl = $account->getMailAccount()->getOauthTokenTtl();
if ($storedTtl !== null && $expiresAt <= $storedTtl + 60 && $account->getMailAccount()->getOauthRefreshToken() !== null) {
// Stored token is as fresh as the session token
return $account;
}

$account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($token->getAccessToken()));
$refreshToken = $token->getRefreshToken();
if ($refreshToken !== null) {
$account->getMailAccount()->setOauthRefreshToken($this->crypto->encrypt($refreshToken));
}
$account->getMailAccount()->setOauthTokenTtl($expiresAt);

$this->logger->debug('Mirrored OIDC session token to mail account {accountId}', [
'accountId' => $account->getId(),
]);
return $account;
}

/**
* Make sure the stored access token is valid, renewing it from the user session or
* with the stored refresh token (background jobs) if it is expired or expiring.
*/
public function refresh(Account $account): Account {
// Only refresh if the token expires in the next minute
$ttl = $account->getMailAccount()->getOauthTokenTtl();
if ($ttl !== null && $this->timeFactory->getTime() <= ($ttl - 60)) {
return $account;
}

// Prefer the live session token on interactive requests
$account = $this->updateFromSession($account);
$ttl = $account->getMailAccount()->getOauthTokenTtl();
if ($ttl !== null && $this->timeFactory->getTime() <= ($ttl - 60)) {
return $account;
}

$encryptedRefreshToken = $account->getMailAccount()->getOauthRefreshToken();
if ($encryptedRefreshToken === null) {
// Account has not been seeded with a token yet
return $account;
}

$providerConfig = $this->getProviderConfig();
if ($providerConfig === null) {
return $account;
}
[$tokenEndpoint, $clientId, $clientSecret] = $providerConfig;

try {
$refreshToken = $this->crypto->decrypt($encryptedRefreshToken);
} catch (Exception $e) {
$this->logger->warning('Could not decrypt OIDC refresh token for account {accountId}: ' . $e->getMessage(), [
'exception' => $e,
'accountId' => $account->getId(),
]);
return $account;
}

$httpClient = $this->clientService->newClient();
try {
$response = $httpClient->post($tokenEndpoint, [
'body' => [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
],
]);
} catch (Exception $e) {
$this->logger->warning('Could not refresh OIDC token for account {accountId}: ' . $e->getMessage(), [
'exception' => $e,
'accountId' => $account->getId(),
]);
return $account;
}

$data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
$account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($data['access_token']));
if (isset($data['refresh_token'])) {
// The IdP may rotate the refresh token
$account->getMailAccount()->setOauthRefreshToken($this->crypto->encrypt($data['refresh_token']));
}
$account->getMailAccount()->setOauthTokenTtl($this->timeFactory->getTime() + $data['expires_in']);

return $account;
}

/**
* Read the token endpoint and client credentials from the user_oidc provider.
*
* @return array{0: string, 1: string, 2: string}|null [token endpoint, client id, client secret]
*/
private function getProviderConfig(): ?array {
if (!class_exists(\OCA\UserOIDC\Db\ProviderMapper::class)) {
$this->logger->debug('Cannot refresh OIDC mail token, user_oidc is not installed');
return null;
}

try {
$providerMapper = \OCP\Server::get(\OCA\UserOIDC\Db\ProviderMapper::class);
$providers = $providerMapper->getProviders();
} catch (\Throwable $e) {
$this->logger->warning('Could not read user_oidc providers: ' . $e->getMessage(), [
'exception' => $e,
]);
return null;
}

if ($providers === []) {
$this->logger->debug('Cannot refresh OIDC mail token, no user_oidc provider is configured');
return null;
}
if (count($providers) > 1) {
$this->logger->debug('Multiple user_oidc providers configured, using the first one for mail token refresh');
}
$provider = $providers[0];

$clientSecret = $provider->getClientSecret();
if ($clientSecret !== '') {
try {
$clientSecret = $this->crypto->decrypt($clientSecret);
} catch (Exception $e) {
$this->logger->warning('Could not decrypt user_oidc client secret: ' . $e->getMessage(), [
'exception' => $e,
]);
return null;
}
}

$tokenEndpoint = $this->getTokenEndpoint($provider);
if ($tokenEndpoint === null) {
return null;
}

return [$tokenEndpoint, $provider->getClientId(), $clientSecret];
}

private function getTokenEndpoint(\OCA\UserOIDC\Db\Provider $provider): ?string {
$cached = $this->tokenEndpoints[$provider->getId()] ?? null;
if ($cached !== null) {
return $cached;
}

$discoveryUrl = $provider->getDiscoveryEndpoint();
if ($discoveryUrl === null || $discoveryUrl === '') {
$this->logger->warning('user_oidc provider has no discovery endpoint');
return null;
}

try {
$response = $this->clientService->newClient()->get($discoveryUrl);
$discovery = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
$this->logger->warning('Could not fetch OIDC discovery document: ' . $e->getMessage(), [
'exception' => $e,
]);
return null;
}

$tokenEndpoint = $discovery['token_endpoint'] ?? null;
if (!is_string($tokenEndpoint) || $tokenEndpoint === '') {
$this->logger->warning('OIDC discovery document has no token_endpoint');
return null;
}

$this->tokenEndpoints[$provider->getId()] = $tokenEndpoint;
return $tokenEndpoint;
}
}
Loading