Skip to content
30 changes: 30 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,36 @@
'url' => '/integration/google-auth',
'verb' => 'GET',
],
[
'name' => 'oidcIntegration#index',
'url' => '/api/integration/oidc/providers',
'verb' => 'GET',
],
[
'name' => 'oidcIntegration#create',
'url' => '/api/integration/oidc/providers',
'verb' => 'POST',
],
[
'name' => 'oidcIntegration#update',
'url' => '/api/integration/oidc/providers/{id}',
'verb' => 'POST',
],
[
'name' => 'oidcIntegration#destroy',
'url' => '/api/integration/oidc/providers/{id}',
'verb' => 'DELETE',
],
[
'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
543 changes: 543 additions & 0 deletions doc/oidc-xoauth2.md

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions lib/Controller/OidcIntegrationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?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\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\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.
*/
public function index(): JSONResponse {
return new JSONResponse($this->oidcIntegration->getProviders());
}

public function create(array $data): JSONResponse {
try {
return new JSONResponse($this->oidcIntegration->createProvider($data));
} catch (ValidationException $e) {
return HttpJsonResponse::fail([$e->getFields()]);
} catch (\Exception $e) {
return HttpJsonResponse::fail([$e->getMessage()]);
}
}

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()]);
} catch (\Exception $e) {
return HttpJsonResponse::fail([$e->getMessage()]);
}
}

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.
*/
#[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 (\Exception $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.
*/
#[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 @@ -14,8 +14,10 @@
use OCA\Mail\AppInfo\Application;
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 @@ -89,6 +91,7 @@ public function __construct(
private IAppManager $appManager,
private ContextChatSettingsService $contextChatSettingsService,
private ClassificationSettingsService $classificationSettingsService,
private OidcIntegration $oidcIntegration,
) {
parent::__construct($appName, $request);

Expand Down Expand Up @@ -293,6 +296,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 @@ -135,6 +137,7 @@ class MailAccount extends Entity {
protected $oauthAccessToken;
protected $oauthRefreshToken;
protected $oauthTokenTtl;
protected $oauthNeedsReauth;

/** @var int|null */
protected $draftsMailboxId;
Expand Down Expand Up @@ -267,6 +270,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 @@ -326,6 +330,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