From 63033ce4ec0b475254416ccff1008c2d81161668 Mon Sep 17 00:00:00 2001 From: Leah Date: Thu, 16 Jul 2026 18:24:25 +0200 Subject: [PATCH 1/2] feat(provisioning): authenticate provisioned accounts with the user's OIDC token (XOAUTH2) Add an oidcEnabled option to provisioning configurations. Accounts are provisioned with auth_method=xoauth2 and no stored password: - The user_oidc login token is mirrored into the account row on interactive requests (ExternalTokenRequestedEvent) and refreshed at the IdP token endpoint with the stored refresh token for background jobs, mirroring the Google/Microsoft integrations. - SMTP client creation dispatches a new BeforeSmtpClientCreated event so tokens are also refreshed before sending. - The option is guarded end to end: the admin UI toggle, provisioning validation and the integration itself require the user_oidc app to be installed and enabled. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Leah --- lib/AppInfo/Application.php | 2 + lib/Db/MailAccount.php | 4 +- lib/Db/Provisioning.php | 5 + lib/Db/ProvisioningMapper.php | 2 + lib/Events/BeforeSmtpClientCreated.php | 25 ++ .../Middleware/ProvisioningMiddleware.php | 2 + lib/Integration/OidcIntegration.php | 270 ++++++++++++++++++ lib/Listener/OauthTokenRefreshListener.php | 9 +- .../Version5300Date20260716000000.php | 41 +++ lib/SMTP/SmtpClientFactory.php | 6 + lib/Service/Provisioning/Manager.php | 62 ++++ lib/Settings/AdminSettings.php | 7 + psalm.xml | 1 + src/components/settings/AdminSettings.vue | 4 + .../settings/ProvisioningSettings.vue | 36 ++- .../Unit/Integration/OidcIntegrationTest.php | 157 ++++++++++ tests/Unit/SMTP/SmtpClientFactoryTest.php | 7 +- .../Unit/Service/Provisioning/ManagerTest.php | 26 ++ tests/Unit/Settings/AdminSettingsTest.php | 6 +- tests/stubs/oca_useroidc.php | 64 +++++ 20 files changed, 728 insertions(+), 8 deletions(-) create mode 100644 lib/Events/BeforeSmtpClientCreated.php create mode 100644 lib/Integration/OidcIntegration.php create mode 100644 lib/Migration/Version5300Date20260716000000.php create mode 100644 tests/Unit/Integration/OidcIntegrationTest.php create mode 100644 tests/stubs/oca_useroidc.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 71b50051c4..30b02f4994 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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; @@ -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); diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index f3e8b900cb..4ded9d43cb 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -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() diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php index 31e0a22081..62a815f582 100644 --- a/lib/Db/Provisioning.php +++ b/lib/Db/Provisioning.php @@ -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() @@ -72,6 +74,7 @@ class Provisioning extends Entity implements JsonSerializable { protected $smtpSslMode; protected $masterPasswordEnabled; protected $masterPassword; + protected $oidcEnabled; protected $sieveEnabled; protected $sieveUser; protected $sieveHost; @@ -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'); @@ -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(), diff --git a/lib/Db/ProvisioningMapper.php b/lib/Db/ProvisioningMapper.php index deb46d7263..afe55473a6 100644 --- a/lib/Db/ProvisioningMapper.php +++ b/lib/Db/ProvisioningMapper.php @@ -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'] ?? ''); diff --git a/lib/Events/BeforeSmtpClientCreated.php b/lib/Events/BeforeSmtpClientCreated.php new file mode 100644 index 0000000000..a6cf07ff62 --- /dev/null +++ b/lib/Events/BeforeSmtpClientCreated.php @@ -0,0 +1,25 @@ +account; + } +} diff --git a/lib/Http/Middleware/ProvisioningMiddleware.php b/lib/Http/Middleware/ProvisioningMiddleware.php index 7e5591a049..578b963c16 100644 --- a/lib/Http/Middleware/ProvisioningMiddleware.php +++ b/lib/Http/Middleware/ProvisioningMiddleware.php @@ -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, diff --git a/lib/Integration/OidcIntegration.php b/lib/Integration/OidcIntegration.php new file mode 100644 index 0000000000..d29e7a6bf2 --- /dev/null +++ b/lib/Integration/OidcIntegration.php @@ -0,0 +1,270 @@ + */ + 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); + } + + 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; + } +} diff --git a/lib/Listener/OauthTokenRefreshListener.php b/lib/Listener/OauthTokenRefreshListener.php index 7e705e9472..f542fc99e9 100644 --- a/lib/Listener/OauthTokenRefreshListener.php +++ b/lib/Listener/OauthTokenRefreshListener.php @@ -10,32 +10,37 @@ namespace OCA\Mail\Listener; use OCA\Mail\Events\BeforeImapClientCreated; +use OCA\Mail\Events\BeforeSmtpClientCreated; use OCA\Mail\Integration\GoogleIntegration; use OCA\Mail\Integration\MicrosoftIntegration; +use OCA\Mail\Integration\OidcIntegration; use OCA\Mail\Service\AccountService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; /** - * @template-implements IEventListener + * @template-implements IEventListener */ class OauthTokenRefreshListener implements IEventListener { public function __construct( private GoogleIntegration $googleIntegration, private MicrosoftIntegration $microsoftIntegration, + private OidcIntegration $oidcIntegration, private AccountService $accountService, ) { } #[\Override] public function handle(Event $event): void { - if (!($event instanceof BeforeImapClientCreated)) { + if (!($event instanceof BeforeImapClientCreated) && !($event instanceof BeforeSmtpClientCreated)) { return; } if ($this->googleIntegration->isGoogleOauthAccount($event->getAccount())) { $updated = $this->googleIntegration->refresh($event->getAccount()); } elseif ($this->microsoftIntegration->isMicrosoftOauthAccount($event->getAccount())) { $updated = $this->microsoftIntegration->refresh($event->getAccount()); + } elseif ($this->oidcIntegration->isOidcAccount($event->getAccount())) { + $updated = $this->oidcIntegration->refresh($event->getAccount()); } else { return; } diff --git a/lib/Migration/Version5300Date20260716000000.php b/lib/Migration/Version5300Date20260716000000.php new file mode 100644 index 0000000000..51a1e98273 --- /dev/null +++ b/lib/Migration/Version5300Date20260716000000.php @@ -0,0 +1,41 @@ +getTable('mail_provisionings'); + if (!$provisioningTable->hasColumn('oidc_enabled')) { + $provisioningTable->addColumn('oidc_enabled', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/SMTP/SmtpClientFactory.php b/lib/SMTP/SmtpClientFactory.php index 7d8c34f2f5..ac9f41b5cc 100644 --- a/lib/SMTP/SmtpClientFactory.php +++ b/lib/SMTP/SmtpClientFactory.php @@ -14,8 +14,10 @@ use Horde_Mail_Transport_Smtphorde; use Horde_Smtp_Password_Xoauth2; use OCA\Mail\Account; +use OCA\Mail\Events\BeforeSmtpClientCreated; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Support\HostNameFactory; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\Security\ICrypto; @@ -30,6 +32,7 @@ public function __construct( IConfig $config, ICrypto $crypto, private HostNameFactory $hostNameFactory, + private IEventDispatcher $eventDispatcher, ) { $this->config = $config; $this->crypto = $crypto; @@ -41,6 +44,9 @@ public function __construct( * @return Horde_Mail_Transport */ public function create(Account $account): Horde_Mail_Transport { + $this->eventDispatcher->dispatchTyped( + new BeforeSmtpClientCreated($account) + ); $mailAccount = $account->getMailAccount(); $decryptedPassword = null; diff --git a/lib/Service/Provisioning/Manager.php b/lib/Service/Provisioning/Manager.php index e3ff4d5fe6..9e1d7e1906 100644 --- a/lib/Service/Provisioning/Manager.php +++ b/lib/Service/Provisioning/Manager.php @@ -10,6 +10,7 @@ namespace OCA\Mail\Service\Provisioning; use Horde_Mail_Rfc822_Address; +use OCA\Mail\Account; use OCA\Mail\AppInfo\Application; use OCA\Mail\Db\Alias; use OCA\Mail\Db\AliasMapper; @@ -19,6 +20,7 @@ use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Db\TagMapper; use OCA\Mail\Exception\ValidationException; +use OCA\Mail\Integration\OidcIntegration; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCP\App\IAppManager; @@ -62,6 +64,7 @@ public function __construct( ICacheFactory $cacheFactory, private AccountService $accountService, private ClassificationSettingsService $classificationSettingsService, + private OidcIntegration $oidcIntegration, ) { $this->appManager = $appManager; $this->userManager = $userManager; @@ -256,11 +259,23 @@ public function provisionSingleUser(array $provisionings, IUser $user): bool { return true; } + /** + * @throws ValidationException + */ + private function validateOidcAvailability(array $data): void { + if (($data['oidcEnabled'] ?? false) && !$this->oidcIntegration->isAvailable()) { + $exception = new ValidationException(); + $exception->setField('oidcEnabled', false); + throw $exception; + } + } + /** * @throws ValidationException * @throws \OCP\DB\Exception */ public function newProvisioning(array $data): Provisioning { + $this->validateOidcAvailability($data); $provisioning = $this->provisioningMapper->validate($data); $provisioning = $this->provisioningMapper->insert($provisioning); if ($this->cacheFactory->isAvailable()) { @@ -275,6 +290,7 @@ public function newProvisioning(array $data): Provisioning { * @throws \OCP\DB\Exception */ public function updateProvisioning(array $data): void { + $this->validateOidcAvailability($data); $provisioning = $this->provisioningMapper->validate($data); $this->provisioningMapper->update($provisioning); if ($this->cacheFactory->isAvailable()) { @@ -287,6 +303,20 @@ private function updateAccount(IUser $user, MailAccount $account, Provisioning $ // Set the ID to make sure it reflects when the account switches from one config to another $account->setProvisioningId($config->getId()); + if ($config->getOidcEnabled() === true) { + // Authenticate with the user's OIDC access token (XOAUTH2) instead of a password. + // Tokens are seeded from user_oidc at login and refreshed by OidcIntegration. + $account->setAuthMethod('xoauth2'); + $account->setInboundPassword(null); + $account->setOutboundPassword(null); + } elseif ($account->getAuthMethod() === 'xoauth2') { + // Config switched back from OIDC to password auth + $account->setAuthMethod('password'); + $account->setOauthAccessToken(null); + $account->setOauthRefreshToken(null); + $account->setOauthTokenTtl(null); + } + $account->setEmail($config->buildEmail($user)); $account->setName($this->userManager->getDisplayName($user->getUID())); $account->setInboundUser($config->buildImapUser($user)); @@ -335,6 +365,11 @@ public function updatePassword(IUser $user, ?string $password, array $provisioni return; } + if ($provisioning->getOidcEnabled() === true) { + // OIDC accounts authenticate with the user's access token, never a password + return; + } + // FIXME: Need to check for an empty string here too? // The password is empty (and not null) when using WebAuthn passwordless login. // Maybe research other providers as well. @@ -366,6 +401,33 @@ public function updatePassword(IUser $user, ?string $password, array $provisioni } } + /** + * Mirror the user's current OIDC session token into their provisioned account so + * that background jobs can keep refreshing it after the session is gone. + * + * @param Provisioning[] $provisionings + */ + public function updateOidcToken(IUser $user, array $provisionings): void { + $provisioning = $this->findMatchingConfig($provisionings, $user); + if ($provisioning === null || $provisioning->getOidcEnabled() !== true) { + return; + } + + try { + $mailAccount = $this->mailAccountMapper->findProvisionedAccount($user); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + // Nothing to update + return; + } + + $ttlBefore = $mailAccount->getOauthTokenTtl(); + $this->oidcIntegration->updateFromSession(new Account($mailAccount)); + if ($mailAccount->getOauthTokenTtl() !== $ttlBefore) { + $this->mailAccountMapper->update($mailAccount); + $this->logger->debug('OIDC token of provisioned account updated from session for ' . $user->getUID()); + } + } + /** * @param Provisioning[] $provisionings */ diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index d7658b1b1b..910bd62327 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -12,6 +12,7 @@ use OCA\Mail\AppInfo\Application; use OCA\Mail\Integration\GoogleIntegration; use OCA\Mail\Integration\MicrosoftIntegration; +use OCA\Mail\Integration\OidcIntegration; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AntiSpamService; use OCA\Mail\Service\Classification\ClassificationSettingsService; @@ -31,6 +32,7 @@ public function __construct( private AntiSpamService $antiSpamService, private GoogleIntegration $googleIntegration, private MicrosoftIntegration $microsoftIntegration, + private OidcIntegration $oidcIntegration, private IConfig $config, private AiIntegrationsService $aiIntegrationsService, private Defaults $themingDefaults, @@ -45,6 +47,11 @@ public function getForm() { $this->provisioningManager->getConfigs() ); + $this->initialStateService->provideInitialState( + 'oidc_available', + $this->oidcIntegration->isAvailable() + ); + $this->initialStateService->provideInitialState( 'antispam_setting', [ diff --git a/psalm.xml b/psalm.xml index b2d115ea6d..2e48e5f8ed 100644 --- a/psalm.xml +++ b/psalm.xml @@ -26,6 +26,7 @@ + diff --git a/src/components/settings/AdminSettings.vue b/src/components/settings/AdminSettings.vue index 683154a279..de45f84696 100644 --- a/src/components/settings/AdminSettings.vue +++ b/src/components/settings/AdminSettings.vue @@ -113,6 +113,7 @@ v-if="addNew" :key="formKey" :setting="preview" + :oidc-available="oidcAvailable" :submit="saveNewSettings" :delete-button="false" />

@@ -310,6 +312,7 @@ const googleOauthRedirectUrl = loadState('mail', 'google_oauth_redirect_url', nu const microsoftOauthTenantId = loadState('mail', 'microsoft_oauth_tenant_id', null) ?? undefined const microsoftOauthClientId = loadState('mail', 'microsoft_oauth_client_id', null) ?? undefined const microsoftOauthRedirectUrl = loadState('mail', 'microsoft_oauth_redirect_url', null) +const oidcAvailable = loadState('mail', 'oidc_available', false) export default { name: 'AdminSettings', @@ -343,6 +346,7 @@ export default { microsoftOauthClientId, microsoftOauthDocs: loadState('mail', 'microsoft_oauth_docs'), microsoftOauthRedirectUrl, + oidcAvailable, preview: { provisioningDomain: '', emailTemplate: '', diff --git a/src/components/settings/ProvisioningSettings.vue b/src/components/settings/ProvisioningSettings.vue index ac7be23921..fc0dd13eb7 100644 --- a/src/components/settings/ProvisioningSettings.vue +++ b/src/components/settings/ProvisioningSettings.vue @@ -181,6 +181,30 @@ +
+
+ {{ t('mail', 'OpenID Connect') }} +
+
+
+ + +
+

+ {{ t('mail', 'Authenticate against IMAP and SMTP with the access token of the user\'s OIDC login (user_oidc) instead of a stored password. Requires mail servers that accept XOAUTH2 with the same identity provider.') }} +

+

+ {{ t('mail', 'Requires the user_oidc app to be installed and enabled.') }} +

+
+
{{ t('mail', 'Master password') }} @@ -190,6 +214,7 @@
@@ -393,6 +418,10 @@ export default { required: true, }, + oidcAvailable: { + type: Boolean, + }, + submit: { type: Function, required: true, @@ -426,6 +455,7 @@ export default { smtpSslMode: this.setting.smtpSslMode || 'tls', masterPasswordEnabled: this.setting.masterPasswordEnabled === true, masterPassword: this.setting.masterPassword || '', + oidcEnabled: this.setting.oidcEnabled === true, sieveEnabled: this.setting.sieveEnabled || '', sieveHost: this.setting.sieveHost || '', sievePort: this.setting.sievePort || '', @@ -462,6 +492,7 @@ export default { smtpSslMode: this.smtpSslMode, masterPasswordEnabled: this.masterPasswordEnabled, masterPassword: this.masterPassword, + oidcEnabled: this.oidcEnabled, sieveEnabled: this.sieveEnabled, sieveUser: this.sieveUser, sieveHost: this.sieveHost, @@ -497,6 +528,7 @@ export default { smtpSslMode: this.smtpSslMode, masterPasswordEnabled: this.masterPasswordEnabled, masterPassword: this.masterPassword, + oidcEnabled: this.oidcEnabled, sieveEnabled: this.sieveEnabled, sieveUser: this.sieveUser, sieveHost: this.sieveHost, diff --git a/tests/Unit/Integration/OidcIntegrationTest.php b/tests/Unit/Integration/OidcIntegrationTest.php new file mode 100644 index 0000000000..810708f62e --- /dev/null +++ b/tests/Unit/Integration/OidcIntegrationTest.php @@ -0,0 +1,157 @@ +timeFactory = $this->createMock(ITimeFactory::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->clientService = $this->createMock(IClientService::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->userSession = $this->createMock(IUserSession::class); + + $this->integration = new OidcIntegration( + $this->timeFactory, + $this->crypto, + $this->clientService, + $this->eventDispatcher, + $this->appManager, + $this->userSession, + $this->createMock(LoggerInterface::class), + ); + } + + public function testIsNotAvailableWithoutUserOidc(): void { + $this->appManager->expects($this->once()) + ->method('isEnabledForAnyone') + ->with('user_oidc') + ->willReturn(false); + + $this->assertFalse($this->integration->isAvailable()); + } + + public function testIsOidcAccountForProvisionedXoauth2Account(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProvisioningId(123); + $mailAccount->setAuthMethod('xoauth2'); + $account = new Account($mailAccount); + + $this->assertTrue($this->integration->isOidcAccount($account)); + } + + public function testIsOidcAccountRejectsUnprovisionedXoauth2Account(): void { + $mailAccount = new MailAccount(); + $mailAccount->setAuthMethod('xoauth2'); + $account = new Account($mailAccount); + + $this->assertFalse($this->integration->isOidcAccount($account)); + } + + public function testIsOidcAccountRejectsPasswordAccount(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProvisioningId(123); + $mailAccount->setAuthMethod('password'); + $account = new Account($mailAccount); + + $this->assertFalse($this->integration->isOidcAccount($account)); + } + + public function testRefreshSkipsWhenTokenIsFresh(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProvisioningId(123); + $mailAccount->setAuthMethod('xoauth2'); + $mailAccount->setOauthAccessToken('enc-token'); + $mailAccount->setOauthTokenTtl(10000); + $account = new Account($mailAccount); + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn(9000); + $this->clientService->expects($this->never()) + ->method('newClient'); + + $actual = $this->integration->refresh($account); + + $this->assertSame('enc-token', $actual->getMailAccount()->getOauthAccessToken()); + } + + public function testUpdateFromSessionIgnoresForeignAccounts(): void { + if (!class_exists(\OCA\UserOIDC\Event\ExternalTokenRequestedEvent::class)) { + $this->markTestSkipped('user_oidc is not available'); + } + $mailAccount = new MailAccount(); + $mailAccount->setUserId('bob'); + $mailAccount->setProvisioningId(123); + $mailAccount->setAuthMethod('xoauth2'); + $account = new Account($mailAccount); + $sessionUser = $this->createMock(IUser::class); + $sessionUser->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($sessionUser); + $this->eventDispatcher->expects($this->never()) + ->method('dispatchTyped'); + + $actual = $this->integration->updateFromSession($account); + + $this->assertNull($actual->getMailAccount()->getOauthAccessToken()); + } + + public function testRefreshWithoutRefreshTokenIsANoop(): void { + $mailAccount = new MailAccount(); + $mailAccount->setProvisioningId(123); + $mailAccount->setAuthMethod('xoauth2'); + $mailAccount->setOauthTokenTtl(100); + $account = new Account($mailAccount); + $this->timeFactory->method('getTime') + ->willReturn(9000); + $this->clientService->expects($this->never()) + ->method('newClient'); + + $actual = $this->integration->refresh($account); + + $this->assertNull($actual->getMailAccount()->getOauthAccessToken()); + } +} diff --git a/tests/Unit/SMTP/SmtpClientFactoryTest.php b/tests/Unit/SMTP/SmtpClientFactoryTest.php index 1e9e918874..236dac0dd1 100644 --- a/tests/Unit/SMTP/SmtpClientFactoryTest.php +++ b/tests/Unit/SMTP/SmtpClientFactoryTest.php @@ -15,6 +15,7 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\SMTP\SmtpClientFactory; use OCA\Mail\Support\HostNameFactory; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\Security\ICrypto; use PHPUnit\Framework\MockObject\MockObject; @@ -29,6 +30,9 @@ class SmtpClientFactoryTest extends TestCase { /** @var HostNameFactory|MockObject */ private $hostNameFactory; + /** @var IEventDispatcher|MockObject */ + private $eventDispatcher; + /** @var SmtpClientFactory */ private $factory; @@ -38,8 +42,9 @@ protected function setUp(): void { $this->config = $this->createMock(IConfig::class); $this->crypto = $this->createMock(ICrypto::class); $this->hostNameFactory = $this->createMock(HostNameFactory::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); - $this->factory = new SmtpClientFactory($this->config, $this->crypto, $this->hostNameFactory); + $this->factory = new SmtpClientFactory($this->config, $this->crypto, $this->hostNameFactory, $this->eventDispatcher); } public function testSmtpTransport() { diff --git a/tests/Unit/Service/Provisioning/ManagerTest.php b/tests/Unit/Service/Provisioning/ManagerTest.php index a1af411851..7c54bf22d0 100644 --- a/tests/Unit/Service/Provisioning/ManagerTest.php +++ b/tests/Unit/Service/Provisioning/ManagerTest.php @@ -11,6 +11,7 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\Provisioning; +use OCA\Mail\Exception\ValidationException; use OCA\Mail\Service\Provisioning\Manager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IUser; @@ -373,4 +374,29 @@ public function testNewProvisioning(): void { self::assertInstanceOf(Provisioning::class, $result); } + + public function testNewProvisioningWithOidcNotAvailable(): void { + $this->mock->getParameter('oidcIntegration') + ->expects($this->once()) + ->method('isAvailable') + ->willReturn(false); + $this->mock->getParameter('provisioningMapper') + ->expects($this->never()) + ->method('validate'); + $this->expectException(ValidationException::class); + + $this->manager->newProvisioning([ + 'oidcEnabled' => true, + 'email' => '%USERID%@domain.com', + 'imapUser' => '%USERID%@domain.com', + 'imapHost' => 'mx.domain.com', + 'imapPort' => 993, + 'imapSslMode' => 'ssl', + 'smtpUser' => '%USERID%@domain.com', + 'smtpHost' => 'mx.domain.com', + 'smtpPort' => 567, + 'smtpSslMode' => 'tls', + 'sieveEnabled' => false, + ]); + } } diff --git a/tests/Unit/Settings/AdminSettingsTest.php b/tests/Unit/Settings/AdminSettingsTest.php index cc95185ebb..dfb6ca4d52 100644 --- a/tests/Unit/Settings/AdminSettingsTest.php +++ b/tests/Unit/Settings/AdminSettingsTest.php @@ -37,13 +37,17 @@ public function testGetSection() { } public function testGetForm() { - $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(14)) + $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(15)) ->method('provideInitialState') ->withConsecutive( [ 'provisioning_settings', $this->anything() ], + [ + 'oidc_available', + $this->anything() + ], [ 'antispam_setting', $this->anything() diff --git a/tests/stubs/oca_useroidc.php b/tests/stubs/oca_useroidc.php new file mode 100644 index 0000000000..c033ff0dc8 --- /dev/null +++ b/tests/stubs/oca_useroidc.php @@ -0,0 +1,64 @@ + Date: Fri, 17 Jul 2026 11:04:18 +0200 Subject: [PATCH 2/2] docs: add setup guide for OIDC access token (XOAUTH2) provisioning Document the oidcEnabled provisioning option end to end: the Authentik provider + federation setup, the Dovecot oauth2 passdb and Postfix submission config, the Nextcloud occ/provisioning steps, verification (including the background refresh path) and troubleshooting. Includes a ready-to-run local Dovecot + Postfix pair for the docker-dev environment. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Leah --- doc/oidc-xoauth2.md | 438 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 doc/oidc-xoauth2.md diff --git a/doc/oidc-xoauth2.md b/doc/oidc-xoauth2.md new file mode 100644 index 0000000000..03496b6f7d --- /dev/null +++ b/doc/oidc-xoauth2.md @@ -0,0 +1,438 @@ + + +# Provisioning with OIDC access tokens (XOAUTH2) + +Minimal end-to-end setup for the `oidcEnabled` provisioning option: Nextcloud Mail +authenticates to IMAP/SMTP with the logged-in user's OIDC access token instead of a +stored password. Written for the +[nextcloud-docker-dev](https://github.com/nextcloud/nextcloud-docker-dev) environment +(Authentik at `http://authentik.local`, Nextcloud at `http://nextcloud.local`), but the +config translates 1:1 to any Authentik + Dovecot 2.4 + Postfix deployment. + +This flow has been verified end to end on a fully local docker-dev stack (Authentik +2026.5.5 + Dovecot 2.4.4 + Postfix): SSO login → account auto-provisioned → IMAP sync +and message send over XOAUTH2 → background sync after the access token expired +(refresh-token path, no session). + +## How it works + +``` +Browser ──SSO──> Authentik ──token──> user_oidc (session) + │ mirrored on Mail requests, + │ refreshed via refresh token in cron + ▼ + oc_mail_accounts (encrypted) + │ XOAUTH2 (user=, auth=Bearer ) + ┌───────────┴───────────┐ + ▼ ▼ + Dovecot :993 Postfix :587 ──SASL──> Dovecot + │ (introspects the token + ▼ with its own client creds) + Authentik /introspect/ +``` + +Requirements: the access token must carry an `email` claim, Dovecot keys mailboxes on +that claim, and the XOAUTH2 `user=` must equal it — Mail sends the account email, so the +provisioning email template must produce the same address the IdP puts in the token. + +## 1. Authentik + +Log in as `akadmin` and create **two OAuth2/OpenID providers**: + +**Provider "Nextcloud"** (drives the SSO login): + +- Client type: *Confidential*, Client ID: `nextcloud` +- Redirect URIs (strict): + - `http://nextcloud.local/index.php/apps/user_oidc/code` + - `http://nextcloud.local/apps/user_oidc/code` + - type *logout*: `http://nextcloud.local/` (for RP-initiated logout) +- Signing key: select the self-signed certificate (**RS256** — user_oidc rejects the + HS256 default) +- Scopes: `openid`, `email`, `profile`, `offline_access` (offline_access ⇒ refresh + tokens ⇒ background sync keeps working after the access token expires) +- Invalidation flow: `default-invalidation-flow` if logging out of Nextcloud should end + the whole SSO session + +**Provider "Dovecot"** (introspection credential only — no login, no redirect URIs): + +- Client type: *Confidential*, Client ID: `dovecot`, note the client secret + +**Federation** (the critical, easy-to-miss step): edit the **Nextcloud** provider → +*Federated OIDC Providers* → add **Dovecot**. Direction matters: the *issuing* provider +lists the *introspecting* one. Reversed, every introspection returns `{"active": false}`. + +> **Authentik version**: cross-provider introspection requires Authentik ≥ 2026.x — +> verified working on 2026.5.5. On 2025.10.x the identical configuration answers every +> federated introspection with `{"active": false}` (docker-dev pins 2025.10 by default; +> set `AUTHENTIK_TAG=2026.5.5` in `.env`). + +Create an **application** for the Nextcloud provider (slug `nextcloud`) and a test user +whose **email domain matches your mail domain**. + +## 2. Dovecot (2.4) + +``` +# dovecot.conf (fragments) +auth_mechanisms { + plain = yes # optional, for non-OIDC clients + login = yes + oauthbearer = yes + xoauth2 = yes +} + +passdb oauth2 { +} +oauth2 { + # client id/secret of the *Dovecot* provider, embedded in the URL + introspection_url = https://dovecot:@authentik.local/application/o/introspect/ + introspection_mode = post + username_attribute = email # mailbox = the token's email claim + active_attribute = active + active_value = true +} + +mail_home = /var/vmail/%{user | domain}/%{user | username} +mail_driver = maildir +mail_path = ~/mail + +# Auto-create the folders Mail needs — it refuses to send without a Sent mailbox +namespace inbox { inbox = yes separator = / } +mailbox Drafts { special_use = \Drafts auto = subscribe } +mailbox Sent { special_use = \Sent auto = subscribe } +mailbox Trash { special_use = \Trash auto = subscribe } +mailbox Spam { special_use = \Junk auto = subscribe } + +# SASL service for Postfix submission +service auth { + inet_listener auth { port = 12 } +} +``` + +You also need a userdb (static or LDAP) resolving the email to uid/gid/home. With a +static userdb, set `allow_all_users = yes` — otherwise LMTP delivery fails its userdb +lookup, because the oauth2 passdb cannot answer lookups without a token: + +``` +userdb static { + allow_all_users = yes + fields { + uid = 1000 + gid = 1000 + home = /var/vmail/%{user | domain}/%{user | username} + } +} +``` + +On the minimal `dovecot/dovecot` image, also set `default_login_user`, +`default_internal_user` and `default_internal_group` to `vmail` (the image has no +`dovenull` user), and make sure the mail volume is writable by that uid. + +A complete, ready-to-run local pair (Dovecot 2.4.4 + Postfix on docker compose, joining +the docker-dev network) is given in the [appendix](#appendix-ready-to-run-local-pair). + +## 3. Postfix (submission :587) + +``` +# master.cf +submission inet n - n - - smtpd + -o smtpd_tls_security_level=encrypt + -o smtpd_sasl_auth_enable=yes + -o smtpd_client_restrictions=permit_sasl_authenticated,reject + -o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch + # Authentik RS256 tokens make the XOAUTH2 SASL blob ~2.4 KB — over the 2048 + # default line limit. Without this every AUTH is truncated ("invalid base64"). + -o line_length_limit=8192 +``` + +``` +# main.cf +smtpd_sasl_type = dovecot +smtpd_sasl_path = inet::12 +smtpd_sasl_security_options = noanonymous +# Dovecot returns the email as SASL username; senders may only use their own address +smtpd_sender_login_maps = regexp:/etc/postfix/sender_login_maps.regexp +``` + +``` +# /etc/postfix/sender_login_maps.regexp — identity map: login == allowed sender +/^(.+)$/ ${1} +``` + +## 4. Nextcloud + +```sh +occ app:enable user_oidc +occ user_oidc:provider Authentik \ + --clientid=nextcloud \ + --clientsecret= \ + --discoveryuri=https://authentik.local/application/o/nextcloud/.well-known/openid-configuration \ + --scope="openid email profile offline_access" \ + --unique-uid=0 --mapping-uid=preferred_username \ + --send-id-token-hint=1 +# Keep the login token so Mail can mirror it (and single logout works) +occ config:app:set user_oidc store_login_token --value=1 + +# The first XOAUTH2 login triggers a *cold* token introspection at the IdP, which can +# exceed Mail's 5s default IMAP timeout (symptom: spurious "denied authentication" +# followed by a rate-limit lockout). +occ config:system:set app.mail.imap.timeout --value=20 --type=integer +``` + +Then in **Admin settings → Groupware → Mail app**, add a provisioning configuration: + +| Setting | Value | +|---|---| +| Provisioning domain | your mail domain (or `*`) | +| Email template | `%EMAIL%` | +| IMAP user / SMTP user | `%EMAIL%` | +| IMAP host/port | your Dovecot, `993` / SSL — **the host must match the TLS cert SAN** | +| SMTP host/port | your Postfix, `587` / STARTTLS | +| OpenID Connect | ✅ *Use the user's OIDC access token (XOAUTH2)* | + +## 5. Verify + +1. Log in to Nextcloud via SSO as the test user, open Mail → the account appears and + syncs without ever asking for a password. +2. Send a mail to yourself → lands in INBOX, copy in Sent. +3. Background path: force-expire the token and sync without a session — + ```sh + # UPDATE oc_mail_accounts SET oauth_token_ttl = 1 WHERE id = ; + occ mail:account:sync + ``` + The sync must succeed and `oauth_token_ttl` move into the future (refreshed via the + stored refresh token against the IdP token endpoint). + +## Troubleshooting + +- **"Mail server denied authentication" on the first attempt, works later** — cold + introspection vs. the IMAP timeout, see `app.mail.imap.timeout` above. After 3 + failures Mail's rate limiter blocks the bucket for up to 3 h (`mail_imap_ratelimit` + keys in the distributed cache). +- **Every login fails, Dovecot logs show introspection `active: false`** — the + federation is missing or reversed (see step 1). +- **SMTP `535 invalid base64`** — `line_length_limit` not raised on the submission port. +- **SMTP `553 not owned by user`** — sender ≠ SASL login; aliases need entries in + `smtpd_sender_login_maps`. +- **`occ mail:account:diagnose`** and `occ config:system:set app.mail.debug --value=true + --type=boolean` (protocol log in `data/mail---imap.log`) are your friends. + +## Appendix: ready-to-run local pair + +A self-contained Dovecot + Postfix pair for the docker-dev environment. It joins the +docker-dev docker network so the containers resolve `authentik.local` / `nextcloud.local`, +and exposes IMAP on `127.0.0.1:1143` and submission on `127.0.0.1:1587` for host-side +testing. No TLS — this is a local test rig, not a deployment. + +### 0. Bump docker-dev's Authentik + +docker-dev pins an Authentik version that predates federated introspection. In the +docker-dev repo root, set the tag in `.env` and recreate: + +```sh +echo 'AUTHENTIK_TAG=2026.5.5' >> .env +docker compose up -d authentik authentik-worker +``` + +Then create the two providers, federation, application and test user from step 1 (the +Authentik admin UI, or its API). + +### 1. Create the directory + +All paths below are relative to `data/oidc-mail/` in the docker-dev checkout (the `data/` +dir is bind-mounted, so anything here is reachable from the host): + +```sh +mkdir -p data/oidc-mail/dovecot data/oidc-mail/postfix +cd data/oidc-mail +``` + +### 2. `docker-compose.yml` + +```yaml +services: + dovecot: + image: dovecot/dovecot:2.4.4 + ports: + - "127.0.0.1:1143:143" + volumes: + # rendered from dovecot.conf.template (secret substituted on the host) + - ./dovecot/dovecot.conf:/etc/dovecot/dovecot.conf:ro + - vmail:/var/vmail + networks: + default: + aliases: [dovecot.local] + + postfix: + build: ./postfix + ports: + - "127.0.0.1:1587:587" + depends_on: [dovecot] + networks: + default: + aliases: [postfix.local] + +volumes: + vmail: + +networks: + default: + name: master_default # the docker-dev network + external: true +``` + +### 3. `dovecot/dovecot.conf.template` + +The Dovecot image is minimal (no `sed`), so the introspection client secret is +substituted **on the host** and the rendered file is mounted (see step 6). + +``` +dovecot_config_version = 2.4.0 +dovecot_storage_version = 2.4.0 + +log_path = /dev/stderr +auth_verbose = yes +protocols = imap lmtp + +# The image has no dovenull user +default_login_user = vmail +default_internal_user = vmail +default_internal_group = vmail + +# Local test rig: no TLS +ssl = no +auth_allow_cleartext = yes + +auth_mechanisms { + plain = yes + login = yes + oauthbearer = yes + xoauth2 = yes +} + +passdb oauth2 { +} +oauth2 { + introspection_url = http://dovecot:__DOVECOT_CLIENT_SECRET__@authentik.local/application/o/introspect/ + introspection_mode = post + username_attribute = email + active_attribute = active + active_value = true +} + +userdb static { + allow_all_users = yes + fields { + uid = 1000 + gid = 1000 + home = /var/vmail/%{user | domain}/%{user | username} + } +} + +mail_driver = maildir +mail_home = /var/vmail/%{user | domain}/%{user | username} +mail_path = ~/mail +mailbox_list_index = yes + +namespace inbox { + inbox = yes + separator = / +} + +mailbox Drafts { special_use = \Drafts auto = subscribe } +mailbox Sent { special_use = \Sent auto = subscribe } +mailbox Trash { special_use = \Trash auto = subscribe } +mailbox Spam { special_use = \Junk auto = subscribe } + +service imap-login { inet_listener imap { port = 143 } } +service lmtp { inet_listener lmtp { port = 24 } } # delivery from Postfix +service auth { inet_listener auth { port = 12 } } # SASL for Postfix submission +``` + +### 4. `postfix/` files + +`postfix/Dockerfile`: + +```dockerfile +FROM alpine:3.22 +RUN apk add --no-cache postfix +COPY main.cf /etc/postfix/main.cf +COPY sender_login_maps.regexp /etc/postfix/sender_login_maps.regexp +COPY submission.cf /tmp/submission.cf +RUN cat /tmp/submission.cf >> /etc/postfix/master.cf && rm /tmp/submission.cf +CMD ["postfix", "start-fg"] +``` + +`postfix/main.cf`: + +``` +compatibility_level = 3.6 +maillog_file = /dev/stdout +myhostname = postfix.local +mydomain = example.local +myorigin = $mydomain +mydestination = + +virtual_mailbox_domains = example.local +virtual_transport = lmtp:inet:dovecot:24 + +smtpd_sasl_type = dovecot +smtpd_sasl_path = inet:dovecot:12 +smtpd_sasl_security_options = noanonymous +smtpd_sender_login_maps = regexp:/etc/postfix/sender_login_maps.regexp + +smtpd_relay_restrictions = permit_sasl_authenticated, reject_unauth_destination +smtpd_recipient_restrictions = permit_sasl_authenticated, reject_unauth_destination +``` + +`postfix/submission.cf` (appended to `master.cf` at build time): + +``` +submission inet n - n - - smtpd + -o syslog_name=postfix/submission + -o smtpd_tls_security_level=none + -o smtpd_sasl_auth_enable=yes + -o smtpd_client_restrictions=permit_sasl_authenticated,reject + -o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch + -o line_length_limit=8192 +``` + +`postfix/sender_login_maps.regexp`: + +``` +/^(.+)$/ ${1} +``` + +### 5. Render the Dovecot config with the introspection secret + +```sh +# is the client secret of the "Dovecot" Authentik provider +sed "s|__DOVECOT_CLIENT_SECRET__||" \ + dovecot/dovecot.conf.template > dovecot/dovecot.conf +``` + +### 6. Prepare the mail volume and start + +The image's `vmail` user is uid 1000; the named volume must be writable by it: + +```sh +docker compose up -d # creates the vmail volume +docker run --rm -v oidc-mail_vmail:/var/vmail alpine:3.22 chown 1000:1000 /var/vmail +docker compose up -d --build # (re)build postfix and start both +``` + +Check the logs — Dovecot should report `starting up for imap, lmtp` with no fatal +errors: + +```sh +docker compose logs -f dovecot postfix +``` + +### 7. Point Mail at it + +Use these values in step 4's `occ` command and the provisioning config: IMAP host +`dovecot.local` port `143` SSL *none*, SMTP host `postfix.local` port `587` SSL *none*, +provisioning domain `example.local` (matching the test user's email). Reaching the pair +from the **host** instead (e.g. an IMAP client) uses `127.0.0.1:1143` / `127.0.0.1:1587`.