Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6d3c2e9
feat: generic OIDC/XOAUTH2 support for individual mail accounts
LeahAuroraV Jul 18, 2026
e4cb626
Merge branch 'main' into feat/oidc-individual
LeahAuroraV Jul 18, 2026
634aec0
feat: ask the user to reconnect when an OIDC grant can no longer be r…
LeahAuroraV Jul 19, 2026
92e1a99
Merge branch 'nextcloud:main' into feat/oidc-individual
LeahAuroraV Jul 19, 2026
787b699
feat: surface the OIDC reconnect dialog after a failed connection
LeahAuroraV Jul 20, 2026
2972998
test: include oauthNeedsReauth in the MailAccount serialization asser…
LeahAuroraV Jul 20, 2026
66d05ad
refactor: improvements and clean-up after self-review
LeahAuroraV Jul 20, 2026
d88221f
Merge branch 'nextcloud:main' into feat/oidc-individual
LeahAuroraV Jul 20, 2026
cd96008
Addressed review feedback
LeahAuroraV Jul 21, 2026
8c2768c
Merge branch 'nextcloud:main' into feat/oidc-individual
LeahAuroraV Jul 21, 2026
833ea10
test: follow the OIDC controller and service contracts from the review
LeahAuroraV Jul 21, 2026
b83cd42
Merge branch 'main' into feat/oidc-individual
LeahAuroraV Jul 21, 2026
abc7df2
Merge branch 'nextcloud:main' into feat/oidc-individual
LeahAuroraV Jul 21, 2026
f64f6fd
Merge branch 'nextcloud:main' into feat/oidc-individual
LeahAuroraV Jul 22, 2026
4f5b606
fix(oidc): add license header and code style to the provider exception
LeahAuroraV Jul 22, 2026
1ec3bdd
Merge remote-tracking branch 'origin/feat/oidc-individual' into feat/…
LeahAuroraV Jul 22, 2026
4016434
Merge branch 'main' into feat/oidc-individual
LeahAuroraV Jul 28, 2026
6b62f8f
rename: Migration from 5110 to 5011
LeahAuroraV Jul 28, 2026
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
11 changes: 11 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,16 @@
'url' => '/integration/google-auth',
'verb' => 'GET',
],
[
'name' => 'oidcIntegration#authorize',
'url' => '/integration/oidc-auth/start',
'verb' => 'GET',
],
[
'name' => 'oidcIntegration#oauthRedirect',
'url' => '/integration/oidc-auth',
'verb' => 'GET',
],
[
'name' => 'microsoftIntegration#configure',
'url' => '/api/integration/microsoft',
Expand Down Expand Up @@ -544,6 +554,7 @@
'localAttachments' => ['url' => '/api/attachments'],
'mailboxes' => ['url' => '/api/mailboxes'],
'messages' => ['url' => '/api/messages'],
'oidcIntegration' => ['url' => '/api/integration/oidc/providers'],
'outbox' => ['url' => '/api/outbox'],
'preferences' => ['url' => '/api/preferences'],
'smimeCertificates' => ['url' => '/api/smime/certificates'],
Expand Down
543 changes: 543 additions & 0 deletions doc/oidc-xoauth2.md

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions lib/Controller/OidcIntegrationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Controller;

use OCA\Mail\AppInfo\Application;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\InvalidOauthStateException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\ValidationException;
use OCA\Mail\Http\JsonResponse as HttpJsonResponse;
use OCA\Mail\Http\TrapError;
use OCA\Mail\IMAP\MailboxSync;
use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\OauthStateService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\StandaloneTemplateResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;

#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class OidcIntegrationController extends Controller {
public function __construct(
IRequest $request,
private ?string $userId,
private OidcIntegration $oidcIntegration,
private AccountService $accountService,
private LoggerInterface $logger,
private MailboxSync $mailboxSync,
private OauthStateService $oauthStateService,
) {
parent::__construct(Application::APP_ID, $request);
}

/**
* List all configured OIDC providers (admin). Client secrets are masked.
*/
#[TrapError]
public function index(): JSONResponse {
return new JSONResponse($this->oidcIntegration->getProviders());
}

#[TrapError]
public function create(array $data): JSONResponse {
try {
return new JSONResponse($this->oidcIntegration->createProvider($data));
} catch (ValidationException $e) {
return HttpJsonResponse::fail([$e->getFields()], Http::STATUS_UNPROCESSABLE_ENTITY);
}
}

#[TrapError]
public function update(int $id, array $data): JSONResponse {
try {
return new JSONResponse(
$this->oidcIntegration->updateProvider(array_merge($data, ['id' => $id])),
);
} catch (ValidationException $e) {
return HttpJsonResponse::fail([$e->getFields()], Http::STATUS_UNPROCESSABLE_ENTITY);
}
}

#[TrapError]
public function destroy(int $id): JSONResponse {
$this->oidcIntegration->deleteProvider($id);
return new JSONResponse([]);
}

/**
* Start the interactive consent flow: resolve the provider's discovery document
* and redirect the popup to the IdP authorization endpoint. Opened as a top-level
* navigation, so it carries no CSRF token.
*/
#[TrapError]
#[NoAdminRequired]
#[NoCSRFRequired]
public function authorize(int $providerId, string $state): Response {
$provider = $this->oidcIntegration->getProvider($providerId);
if ($provider === null) {
$this->logger->warning('Cannot start OIDC consent flow: unknown provider {providerId}', [
'providerId' => $providerId,
]);
return $this->done();
}

try {
$url = $this->oidcIntegration->getAuthorizationUrl($provider, $state);
} catch (ServiceException $e) {
$this->logger->error('Cannot start OIDC consent flow: ' . $e->getMessage(), [
'exception' => $e,
'providerId' => $providerId,
]);
return $this->done();
}

return new RedirectResponse($url);
}

/**
* OAuth authorization-code callback. Exchanges the code for tokens and stores
* them on the account matched by the CSRF state.
*/
#[TrapError]
#[NoAdminRequired]
#[NoCSRFRequired]
public function oauthRedirect(?string $code, ?string $state, ?string $error): Response {
if ($this->userId === null || !isset($code, $state)) {
return $this->done();
}

try {
$accountId = $this->oauthStateService->validateAndConsume($state, $this->userId);
$account = $this->accountService->find($this->userId, $accountId);
} catch (InvalidOauthStateException|ClientException $e) {
$this->logger->warning('Cannot link OIDC account: invalid OAuth state', [
'exception' => $e,
]);
return $this->done();
}

$provider = $this->oidcIntegration->getProviderForAccount($account);
if ($provider === null) {
$this->logger->warning('Cannot link OIDC account {accountId}: no provider matches its email domain', [
'accountId' => $account->getId(),
]);
return $this->done();
}

$updated = $this->oidcIntegration->finishConnect($provider, $account, $code);
$this->accountService->update($updated->getMailAccount());
try {
$this->mailboxSync->sync($account, $this->logger);
} catch (ServiceException $e) {
$this->logger->error('Failed syncing the newly linked OIDC account: ' . $e->getMessage(), [
'exception' => $e,
]);
}
return $this->done();
}

private function done(): StandaloneTemplateResponse {
return new StandaloneTemplateResponse(
Application::APP_ID,
'oauth_done',
[],
'guest',
);
}
}
21 changes: 21 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
use OCA\Mail\ConfigLexicon;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Contracts\IUserPreferences;
use OCA\Mail\Db\OidcProvider;
use OCA\Mail\Db\SmimeCertificate;
use OCA\Mail\Db\TagMapper;
use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\AliasesService;
Expand Down Expand Up @@ -92,6 +94,7 @@ public function __construct(
private ContextChatSettingsService $contextChatSettingsService,
private ClassificationSettingsService $classificationSettingsService,
private IAppConfig $appConfig,
private OidcIntegration $oidcIntegration,
) {
parent::__construct($appName, $request);

Expand Down Expand Up @@ -296,6 +299,24 @@ public function index(): TemplateResponse {
);
}

$oidcAuthorizeUrl = $this->urlGenerator->linkToRouteAbsolute('mail.oidcIntegration.authorize');
$this->initialStateService->provideInitialState(
'oidc_providers',
array_map(static function (OidcProvider $provider) use ($oidcAuthorizeUrl): array {
return [
'name' => $provider->getName(),
'emailDomain' => $provider->getEmailDomain(),
'imapHost' => $provider->getImapHost(),
'imapPort' => $provider->getImapPort(),
'imapSslMode' => $provider->getImapSslMode(),
'smtpHost' => $provider->getSmtpHost(),
'smtpPort' => $provider->getSmtpPort(),
'smtpSslMode' => $provider->getSmtpSslMode(),
'authorizeUrl' => $oidcAuthorizeUrl . '?providerId=' . $provider->getId() . '&state=_state_',
];
}, $this->oidcIntegration->getProviders()),
);

// Disable snooze and scheduled send in frontend if ajax cron is used because it is unreliable
$cronMode = $this->config->getAppValue('core', 'backgroundjobs_mode', 'ajax');
$this->initialStateService->provideInitialState(
Expand Down
5 changes: 5 additions & 0 deletions lib/Db/MailAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
* @method void setOauthAccessToken(string $token)
* @method string|null getOauthRefreshToken()
* @method void setOauthRefreshToken(string $token)
* @method bool|null getOauthNeedsReauth()
* @method void setOauthNeedsReauth(bool $needsReauth)
* @method int|null getOauthTokenTtl()
* @method void setOauthTokenTtl(int|null $ttl)
* @method int|null getSmimeCertificateId()
Expand Down Expand Up @@ -141,6 +143,7 @@ class MailAccount extends Entity {
protected $oauthAccessToken;
protected $oauthRefreshToken;
protected $oauthTokenTtl;
protected $oauthNeedsReauth;

/** @var int|null */
protected $draftsMailboxId;
Expand Down Expand Up @@ -283,6 +286,7 @@ public function __construct(array $params = []) {
$this->addType('provisioningId', 'integer');
$this->addType('order', 'integer');
$this->addType('showSubscribedOnly', 'boolean');
$this->addType('oauthNeedsReauth', 'boolean');
$this->addType('personalNamespace', 'string');
$this->addType('draftsMailboxId', 'integer');
$this->addType('sentMailboxId', 'integer');
Expand Down Expand Up @@ -344,6 +348,7 @@ public function toJson() {
'archiveMailboxId' => $this->getArchiveMailboxId(),
'snoozeMailboxId' => $this->getSnoozeMailboxId(),
'sieveEnabled' => ($this->isSieveEnabled() === true),
'oauthNeedsReauth' => ($this->getOauthNeedsReauth() === true),
'signatureAboveQuote' => ($this->isSignatureAboveQuote() === true),
'signatureMode' => $this->getSignatureMode(),
'smimeCertificateId' => $this->getSmimeCertificateId(),
Expand Down
106 changes: 106 additions & 0 deletions lib/Db/OidcProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Db;

use JsonSerializable;
use OCP\AppFramework\Db\Entity;
use ReturnTypeWillChange;

/**
* An admin-configured OIDC provider used to authenticate individual mail accounts
* over XOAUTH2. Matched to an account by the user's email domain.
*
* @method string getName()
* @method void setName(string $name)
* @method string getEmailDomain()
* @method void setEmailDomain(string $emailDomain)
* @method string getImapHost()
* @method void setImapHost(string $imapHost)
* @method int getImapPort()
* @method void setImapPort(int $imapPort)
* @method string getImapSslMode()
* @method void setImapSslMode(string $imapSslMode)
* @method string getSmtpHost()
* @method void setSmtpHost(string $smtpHost)
* @method int getSmtpPort()
* @method void setSmtpPort(int $smtpPort)
* @method string getSmtpSslMode()
* @method void setSmtpSslMode(string $smtpSslMode)
* @method string getClientId()
* @method void setClientId(string $clientId)
* @method string|null getClientSecret()
* @method void setClientSecret(?string $clientSecret)
* @method string getDiscoveryUrl()
* @method void setDiscoveryUrl(string $discoveryUrl)
* @method bool getManualEndpoints()
* @method void setManualEndpoints(bool $manualEndpoints)
* @method string getAuthorizationEndpoint()
* @method void setAuthorizationEndpoint(string $authorizationEndpoint)
* @method string getTokenEndpoint()
* @method void setTokenEndpoint(string $tokenEndpoint)
* @method string getIntrospectionEndpoint()
* @method void setIntrospectionEndpoint(string $introspectionEndpoint)
* @method string getScope()
* @method void setScope(string $scope)
*/
class OidcProvider extends Entity implements JsonSerializable {
public const CLIENT_SECRET_PLACEHOLDER = '********';

protected $name;
protected $emailDomain;
protected $imapHost;
protected $imapPort;
protected $imapSslMode;
protected $smtpHost;
protected $smtpPort;
protected $smtpSslMode;
protected $clientId;
protected $clientSecret;
protected $discoveryUrl;
protected $manualEndpoints;
protected $authorizationEndpoint;
protected $tokenEndpoint;
protected $introspectionEndpoint;
protected $scope;

public function __construct() {
$this->addType('imapPort', 'integer');
$this->addType('smtpPort', 'integer');
$this->addType('manualEndpoints', 'boolean');
}

/**
* Serialise for the admin UI. The client secret is never sent to the client;
* a placeholder signals whether one is set.
*/
#[\Override]
#[ReturnTypeWillChange]
public function jsonSerialize() {
return [
'id' => $this->getId(),
'name' => $this->getName(),
'emailDomain' => $this->getEmailDomain(),
'imapHost' => $this->getImapHost(),
'imapPort' => $this->getImapPort(),
'imapSslMode' => $this->getImapSslMode(),
'smtpHost' => $this->getSmtpHost(),
'smtpPort' => $this->getSmtpPort(),
'smtpSslMode' => $this->getSmtpSslMode(),
'clientId' => $this->getClientId(),
'clientSecret' => !empty($this->getClientSecret()) ? self::CLIENT_SECRET_PLACEHOLDER : null,
'discoveryUrl' => $this->getDiscoveryUrl(),
'manualEndpoints' => $this->getManualEndpoints(),
'authorizationEndpoint' => $this->getAuthorizationEndpoint(),
'tokenEndpoint' => $this->getTokenEndpoint(),
'introspectionEndpoint' => $this->getIntrospectionEndpoint(),
'scope' => $this->getScope(),
];
}
}
Loading
Loading