-
Notifications
You must be signed in to change notification settings - Fork 332
feat(provisioning): authenticate provisioned accounts with the user's OIDC token (XOAUTH2) #13311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63033ce
07bccea
7a61b59
a76487a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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; | ||
| } | ||
| } |
| 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 { | ||
| /** @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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.