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`. 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 @@ +