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
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