diff --git a/appinfo/routes.php b/appinfo/routes.php
index 0e116e1349..da6766bcd4 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -465,6 +465,16 @@
'url' => '/integration/google-auth',
'verb' => 'GET',
],
+ [
+ 'name' => 'oidcIntegration#authorize',
+ 'url' => '/integration/oidc-auth/start',
+ 'verb' => 'GET',
+ ],
+ [
+ 'name' => 'oidcIntegration#oauthRedirect',
+ 'url' => '/integration/oidc-auth',
+ 'verb' => 'GET',
+ ],
[
'name' => 'microsoftIntegration#configure',
'url' => '/api/integration/microsoft',
@@ -544,6 +554,7 @@
'localAttachments' => ['url' => '/api/attachments'],
'mailboxes' => ['url' => '/api/mailboxes'],
'messages' => ['url' => '/api/messages'],
+ 'oidcIntegration' => ['url' => '/api/integration/oidc/providers'],
'outbox' => ['url' => '/api/outbox'],
'preferences' => ['url' => '/api/preferences'],
'smimeCertificates' => ['url' => '/api/smime/certificates'],
diff --git a/doc/oidc-xoauth2.md b/doc/oidc-xoauth2.md
new file mode 100644
index 0000000000..050ebe0590
--- /dev/null
+++ b/doc/oidc-xoauth2.md
@@ -0,0 +1,543 @@
+
+
+# OpenID Connect (XOAUTH2) for individual mail accounts
+
+Mail can authenticate a user's IMAP/SMTP connection with an OpenID Connect (OIDC)
+access token over the `XOAUTH2` SASL mechanism, instead of storing a password. This
+generalises the built-in Google and Microsoft integrations to any OIDC provider
+(Keycloak, Authentik, Stalwart, …).
+
+An administrator registers one or more **OIDC providers**, each matched to accounts by
+the user's **email domain**. When a user adds a mail account whose address is in a
+configured domain, Mail pre-fills the mail servers and starts an interactive
+authorization-code flow: the user consents at the identity provider, and Mail stores
+the resulting access and refresh tokens on the account.
+
+> This is the *individual account* flow (the user runs the consent popup themselves).
+> Automatic provisioning of accounts without user interaction is a separate feature.
+
+## How it works
+
+```
+Admin registers provider ─► email_domain + IMAP/SMTP host/port/ssl
+ + client_id/secret + discovery URL + scopes
+ │
+User adds account (alice@example.com) ─► domain matches provider → hosts pre-filled
+ │
+Popup → provider authorization endpoint → consent → redirect back with code
+ │
+Server exchanges code for tokens (confidential client, secret stays server-side)
+ │
+IMAP/SMTP over XOAUTH2; the access token is refreshed automatically before it expires
+```
+
+- **Matching key: email domain.** One provider per domain. The provider is a full
+ connection template for that domain (separate IMAP and SMTP host/port/SSL).
+- **Confidential client.** The code→token exchange runs on the server, so the client
+ secret is never exposed to the browser. PKCE is not required (but the flow tolerates
+ providers that mandate it).
+- **Endpoints come from discovery.** By default only the discovery URL
+ (`…/.well-known/openid-configuration`) is stored; the authorization and token
+ endpoints are fetched from it and cached. Providers without a discovery document
+ can instead define these endpoints manually (see below).
+- **Refresh tokens** are used to keep the connection alive for background sync, so the
+ default scopes include `offline_access`.
+
+## Admin configuration
+
+1. Open **Administration settings → Groupware → Mail** and find the
+ **OpenID Connect integration** section.
+2. Click **Add OIDC provider** and fill in:
+ - **Display name** – free-text label.
+ - **Email domain** – e.g. `example.com`. Accounts with an address in this domain use
+ this provider.
+ - **IMAP** and **SMTP** – host, port and encryption (SSL/TLS, STARTTLS or none).
+ Defaults are `993`/SSL and `587`/STARTTLS. The two hosts may differ.
+ - **Discovery endpoint** – the provider's `.well-known/openid-configuration` URL.
+ Alternatively, tick **Manually define endpoints** and enter the **Authorization
+ endpoint** and **Token endpoint** directly — useful for identity providers that
+ don't publish a discovery document, or to override it. When manual endpoints are
+ used the discovery URL is ignored.
+ - **Client ID** / **Client secret** – credentials of the OAuth client you register
+ with your identity provider (see below). The secret is stored encrypted and shown
+ back only as a placeholder.
+ - **Scopes** – defaults to `openid email offline_access`.
+3. **Save.**
+
+### Registering the OAuth client with your provider
+
+Create a confidential (web) OAuth client in your identity provider and authorize this
+**redirect URL** (shown in the provider form):
+
+```
+https:///index.php/apps/mail/integration/oidc-auth
+```
+
+Grant the client the scopes it needs to issue mail tokens and refresh tokens, and make
+sure the access token it issues is accepted by your IMAP/SMTP server for `XOAUTH2`.
+
+## User setup
+
+1. In Mail, choose **Add mail account** and enter the email address (e.g.
+ `alice@example.com`).
+2. If the domain matches a configured provider, the server fields are filled in and a
+ single sign-on notice appears. Continue.
+3. A popup opens at your identity provider. Sign in and grant access.
+4. The account is created and syncs over XOAUTH2 — no password is stored.
+
+## Token refresh
+
+Before each IMAP connection Mail checks whether the access token is about to expire and,
+if so, refreshes it with the stored refresh token. This runs during background sync as
+well, so accounts keep working without user interaction as long as the refresh token is
+valid. If the provider rotates refresh tokens, the new one is stored on every refresh, so
+a regularly syncing account keeps rolling its grant forward.
+
+## When the grant can no longer be renewed
+
+The refresh token itself eventually expires or is revoked — for example after an extended
+outage where nothing synced, or if the provider never issued one (no `offline_access`).
+OAuth defines no way to know that lifetime up front, so Mail reacts to it instead:
+
+1. A refresh that fails is not trusted on its own — the provider may simply have been
+ unreachable. Mail asks the provider's **introspection endpoint** (RFC 7662) whether the
+ refresh token is still active.
+2. Only a definitive `active: false` (or having no refresh token at all while the access
+ token has expired) marks the account as needing re-authentication. A network error or
+ an unavailable introspection endpoint is treated as transient and changes nothing.
+3. The next time the user opens Mail, a dialog offers to **Reconnect**, which runs the
+ same consent popup as the initial setup and restores the account. The flag is cleared
+ automatically as soon as any refresh or reconnect succeeds.
+
+> Introspection is only available when the provider is configured by discovery URL — a
+> discovery document advertises `introspection_endpoint`. With manually defined endpoints
+> there is nothing to introspect, so a failed refresh is always treated as transient and
+> the account is never flagged automatically.
+
+## Troubleshooting
+
+- **No SSO notice when adding the account** – the email domain does not exactly match a
+ configured provider's *Email domain*. Matching is case-insensitive but otherwise exact.
+- **Popup returns but the account is not authorized** – check the Nextcloud log
+ (`mail` app). Common causes: the redirect URL is not authorized at the provider, the
+ discovery URL is unreachable, or the client secret is wrong.
+- **Authentication fails at IMAP/SMTP** – the mail server must accept the provider's
+ access token for `XOAUTH2`. Verify the token audience/scope expected by your mail
+ server.
+- **Discovery changes are not picked up** – the discovery document is cached for a short
+ time; wait for the cache to expire or restart PHP workers.
+
+## Setting up a local test environment
+
+A minimal end-to-end setup on the
+[nextcloud-docker-dev](https://github.com/nextcloud/nextcloud-docker-dev) environment
+(Authentik at `http://authentik.local`, Nextcloud at `http://nextcloud.local`) with a
+local Dovecot 2.4 + Postfix pair that accept `XOAUTH2`. The config translates to any
+Authentik + Dovecot 2.4 + Postfix deployment. Unlike a provisioning setup this flow
+needs no `user_oidc` — Mail is its own OAuth client and the user just consents once.
+
+```
+Browser ── add account (alice@example.local) ──> Mail matches a provider by email domain
+ │
+ └─ popup ─> Authentik /authorize ── sign in + consent ──> redirect back with ?code
+ │
+ Mail exchanges the code server-side (confidential
+ client, secret never in the browser)
+ ▼
+ oc_mail_accounts (access + refresh, encrypted)
+ │ XOAUTH2 (user=,
+ │ auth=Bearer )
+ ┌───────────────────┴───────────────────┐
+ ▼ ▼
+ Dovecot :143 Postfix :587 ──SASL──> Dovecot
+ │ (introspects the token)
+ ▼
+ Authentik /introspect/ (Mail's token is trusted
+ via provider federation)
+```
+
+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 address the user
+signs in with at the IdP must match.
+
+### 1. Authentik
+
+Log in as `akadmin` and create **two OAuth2/OpenID providers**.
+
+**Provider "Mail"** (the client Mail authenticates with):
+
+- Client type: *Confidential*, Client ID: `mail`, note the client secret.
+- Redirect URI (strict): `http://nextcloud.local/index.php/apps/mail/integration/oidc-auth`
+ — this is exactly the "Redirect URL to register with the provider" shown in the
+ provider form.
+- **Grant types: `authorization_code` and `refresh_token`.** A freshly created Authentik
+ provider has an **empty** grant-types list, and then `/authorize` rejects the request
+ with *"Invalid grant_type for provider"* — easy to miss.
+- Signing key: the self-signed certificate (**RS256**).
+- Scopes: `openid`, `email`, `offline_access` (offline_access ⇒ refresh tokens ⇒
+ background sync keeps working after the access token expires).
+
+**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 **Mail** provider →
+*Federated OIDC Providers* → add **Dovecot**. The *issuing* provider lists the
+*introspecting* one. Without this, Dovecot rejects the Mail-issued token and every
+IMAP/SMTP auth fails even though the token itself is valid.
+
+> **Authentik version**: cross-provider introspection requires Authentik ≥ 2026.x
+> (verified on 2026.5.5). On 2025.10.x the identical config answers every federated
+> introspection with `{"active": false}`. docker-dev pins an older tag by default — set
+> `AUTHENTIK_TAG=2026.5.5` in `.env`.
+
+Create an **application** bound to the Mail provider, and a test user whose **email
+domain matches the domain you configure in Mail** (e.g. `alice@example.local`).
+
+### 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 can't 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 is 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
+
+No `user_oidc` needed. Bump the IMAP timeout — the first XOAUTH2 login triggers a *cold*
+token introspection at the IdP which can exceed Mail's 5s default (symptom: spurious
+"denied authentication" followed by a rate-limit lockout):
+
+```sh
+occ config:system:set app.mail.imap.timeout --value=20 --type=integer
+```
+
+Then in **Admin settings → Groupware → Mail → OpenID Connect integration**, click
+**Add OIDC provider** and fill in:
+
+| Setting | Value |
+|---|---|
+| Display name | anything, e.g. `Authentik local` |
+| Email domain | your test user's domain, e.g. `example.local` |
+| IMAP host/port | your Dovecot, e.g. `dovecot.local` / `143` / SSL *none* (local rig) |
+| SMTP host/port | your Postfix, e.g. `postfix.local` / `587` / SSL *none* (local rig) |
+| Discovery endpoint | `http://authentik.local/application/o//.well-known/openid-configuration` (or tick *Manually define endpoints* and paste the authorize/token URLs) |
+| Client ID / secret | `mail` + the secret from step 1 |
+| Scopes | `openid email offline_access` |
+
+### 5. Verify
+
+1. In Mail, **Add mail account**, enter the test user's address (e.g.
+ `alice@example.local`). The mail servers pre-fill and a single-sign-on notice appears —
+ the password field is gone. Hit **Connect to \**.
+2. A popup opens at Authentik → sign in as the test user and consent → the account is
+ created and syncs, no password stored.
+3. Send a mail to yourself → lands in INBOX, copy in Sent.
+4. 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).
+
+### Common test-setup issues
+
+- **`/authorize` redirects back with `error=invalid_request`, Authentik logs "Invalid
+ grant_type for provider"** — the Mail provider's grant-types list is empty; add
+ `authorization_code` and `refresh_token`.
+- **Popup finishes but the account isn't authorized, Dovecot logs introspection
+ `active: false`** — the provider federation is missing or reversed (step 1).
+- **"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).
+- **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 docker-dev. It joins the docker-dev network
+so the containers resolve `authentik.local`, and exposes IMAP on `127.0.0.1:1143` and
+submission on `127.0.0.1:1587` for host-side testing. No TLS — local test rig, not a
+deployment.
+
+**0. Bump docker-dev's Authentik.** In the docker-dev repo root:
+
+```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.
+
+**1. Create the directory** (relative to `data/oidc-mail/` in the docker-dev checkout;
+`data/` is bind-mounted so it 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 image has no `sed`, so the introspection
+secret is substituted on the host and the rendered file is mounted — see step 5):
+
+```
+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
+docker compose logs -f dovecot postfix # dovecot should report "starting up for imap, lmtp"
+```
+
+**7. Point Mail at it.** In the provider form (step 4): IMAP `dovecot.local` port `143`
+SSL *none*, SMTP `postfix.local` port `587` SSL *none*, email domain `example.local`
+(matching the test user). Reaching the pair from the **host** instead uses
+`127.0.0.1:1143` / `127.0.0.1:1587`.
diff --git a/lib/Controller/OidcIntegrationController.php b/lib/Controller/OidcIntegrationController.php
new file mode 100644
index 0000000000..c5b7b2ec91
--- /dev/null
+++ b/lib/Controller/OidcIntegrationController.php
@@ -0,0 +1,163 @@
+oidcIntegration->getProviders());
+ }
+
+ #[TrapError]
+ public function create(array $data): JSONResponse {
+ try {
+ return new JSONResponse($this->oidcIntegration->createProvider($data));
+ } catch (ValidationException $e) {
+ return HttpJsonResponse::fail([$e->getFields()], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ }
+
+ #[TrapError]
+ public function update(int $id, array $data): JSONResponse {
+ try {
+ return new JSONResponse(
+ $this->oidcIntegration->updateProvider(array_merge($data, ['id' => $id])),
+ );
+ } catch (ValidationException $e) {
+ return HttpJsonResponse::fail([$e->getFields()], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ }
+
+ #[TrapError]
+ public function destroy(int $id): JSONResponse {
+ $this->oidcIntegration->deleteProvider($id);
+ return new JSONResponse([]);
+ }
+
+ /**
+ * Start the interactive consent flow: resolve the provider's discovery document
+ * and redirect the popup to the IdP authorization endpoint. Opened as a top-level
+ * navigation, so it carries no CSRF token.
+ */
+ #[TrapError]
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function authorize(int $providerId, string $state): Response {
+ $provider = $this->oidcIntegration->getProvider($providerId);
+ if ($provider === null) {
+ $this->logger->warning('Cannot start OIDC consent flow: unknown provider {providerId}', [
+ 'providerId' => $providerId,
+ ]);
+ return $this->done();
+ }
+
+ try {
+ $url = $this->oidcIntegration->getAuthorizationUrl($provider, $state);
+ } catch (ServiceException $e) {
+ $this->logger->error('Cannot start OIDC consent flow: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'providerId' => $providerId,
+ ]);
+ return $this->done();
+ }
+
+ return new RedirectResponse($url);
+ }
+
+ /**
+ * OAuth authorization-code callback. Exchanges the code for tokens and stores
+ * them on the account matched by the CSRF state.
+ */
+ #[TrapError]
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function oauthRedirect(?string $code, ?string $state, ?string $error): Response {
+ if ($this->userId === null || !isset($code, $state)) {
+ return $this->done();
+ }
+
+ try {
+ $accountId = $this->oauthStateService->validateAndConsume($state, $this->userId);
+ $account = $this->accountService->find($this->userId, $accountId);
+ } catch (InvalidOauthStateException|ClientException $e) {
+ $this->logger->warning('Cannot link OIDC account: invalid OAuth state', [
+ 'exception' => $e,
+ ]);
+ return $this->done();
+ }
+
+ $provider = $this->oidcIntegration->getProviderForAccount($account);
+ if ($provider === null) {
+ $this->logger->warning('Cannot link OIDC account {accountId}: no provider matches its email domain', [
+ 'accountId' => $account->getId(),
+ ]);
+ return $this->done();
+ }
+
+ $updated = $this->oidcIntegration->finishConnect($provider, $account, $code);
+ $this->accountService->update($updated->getMailAccount());
+ try {
+ $this->mailboxSync->sync($account, $this->logger);
+ } catch (ServiceException $e) {
+ $this->logger->error('Failed syncing the newly linked OIDC account: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ }
+ return $this->done();
+ }
+
+ private function done(): StandaloneTemplateResponse {
+ return new StandaloneTemplateResponse(
+ Application::APP_ID,
+ 'oauth_done',
+ [],
+ 'guest',
+ );
+ }
+}
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 1054d94339..a0d11c27f4 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -14,8 +14,10 @@
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Contracts\IUserPreferences;
+use OCA\Mail\Db\OidcProvider;
use OCA\Mail\Db\SmimeCertificate;
use OCA\Mail\Db\TagMapper;
+use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\AliasesService;
@@ -89,6 +91,7 @@ public function __construct(
private IAppManager $appManager,
private ContextChatSettingsService $contextChatSettingsService,
private ClassificationSettingsService $classificationSettingsService,
+ private OidcIntegration $oidcIntegration,
) {
parent::__construct($appName, $request);
@@ -293,6 +296,24 @@ public function index(): TemplateResponse {
);
}
+ $oidcAuthorizeUrl = $this->urlGenerator->linkToRouteAbsolute('mail.oidcIntegration.authorize');
+ $this->initialStateService->provideInitialState(
+ 'oidc_providers',
+ array_map(static function (OidcProvider $provider) use ($oidcAuthorizeUrl): array {
+ return [
+ 'name' => $provider->getName(),
+ 'emailDomain' => $provider->getEmailDomain(),
+ 'imapHost' => $provider->getImapHost(),
+ 'imapPort' => $provider->getImapPort(),
+ 'imapSslMode' => $provider->getImapSslMode(),
+ 'smtpHost' => $provider->getSmtpHost(),
+ 'smtpPort' => $provider->getSmtpPort(),
+ 'smtpSslMode' => $provider->getSmtpSslMode(),
+ 'authorizeUrl' => $oidcAuthorizeUrl . '?providerId=' . $provider->getId() . '&state=_state_',
+ ];
+ }, $this->oidcIntegration->getProviders()),
+ );
+
// Disable snooze and scheduled send in frontend if ajax cron is used because it is unreliable
$cronMode = $this->config->getAppValue('core', 'backgroundjobs_mode', 'ajax');
$this->initialStateService->provideInitialState(
diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php
index 0c1dba01aa..3f6dc02ff1 100644
--- a/lib/Db/MailAccount.php
+++ b/lib/Db/MailAccount.php
@@ -87,6 +87,8 @@
* @method void setOauthAccessToken(string $token)
* @method string|null getOauthRefreshToken()
* @method void setOauthRefreshToken(string $token)
+ * @method bool|null getOauthNeedsReauth()
+ * @method void setOauthNeedsReauth(bool $needsReauth)
* @method int|null getOauthTokenTtl()
* @method void setOauthTokenTtl(int|null $ttl)
* @method int|null getSmimeCertificateId()
@@ -141,6 +143,7 @@ class MailAccount extends Entity {
protected $oauthAccessToken;
protected $oauthRefreshToken;
protected $oauthTokenTtl;
+ protected $oauthNeedsReauth;
/** @var int|null */
protected $draftsMailboxId;
@@ -283,6 +286,7 @@ public function __construct(array $params = []) {
$this->addType('provisioningId', 'integer');
$this->addType('order', 'integer');
$this->addType('showSubscribedOnly', 'boolean');
+ $this->addType('oauthNeedsReauth', 'boolean');
$this->addType('personalNamespace', 'string');
$this->addType('draftsMailboxId', 'integer');
$this->addType('sentMailboxId', 'integer');
@@ -344,6 +348,7 @@ public function toJson() {
'archiveMailboxId' => $this->getArchiveMailboxId(),
'snoozeMailboxId' => $this->getSnoozeMailboxId(),
'sieveEnabled' => ($this->isSieveEnabled() === true),
+ 'oauthNeedsReauth' => ($this->getOauthNeedsReauth() === true),
'signatureAboveQuote' => ($this->isSignatureAboveQuote() === true),
'signatureMode' => $this->getSignatureMode(),
'smimeCertificateId' => $this->getSmimeCertificateId(),
diff --git a/lib/Db/OidcProvider.php b/lib/Db/OidcProvider.php
new file mode 100644
index 0000000000..3d38524447
--- /dev/null
+++ b/lib/Db/OidcProvider.php
@@ -0,0 +1,106 @@
+addType('imapPort', 'integer');
+ $this->addType('smtpPort', 'integer');
+ $this->addType('manualEndpoints', 'boolean');
+ }
+
+ /**
+ * Serialise for the admin UI. The client secret is never sent to the client;
+ * a placeholder signals whether one is set.
+ */
+ #[\Override]
+ #[ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return [
+ 'id' => $this->getId(),
+ 'name' => $this->getName(),
+ 'emailDomain' => $this->getEmailDomain(),
+ 'imapHost' => $this->getImapHost(),
+ 'imapPort' => $this->getImapPort(),
+ 'imapSslMode' => $this->getImapSslMode(),
+ 'smtpHost' => $this->getSmtpHost(),
+ 'smtpPort' => $this->getSmtpPort(),
+ 'smtpSslMode' => $this->getSmtpSslMode(),
+ 'clientId' => $this->getClientId(),
+ 'clientSecret' => !empty($this->getClientSecret()) ? self::CLIENT_SECRET_PLACEHOLDER : null,
+ 'discoveryUrl' => $this->getDiscoveryUrl(),
+ 'manualEndpoints' => $this->getManualEndpoints(),
+ 'authorizationEndpoint' => $this->getAuthorizationEndpoint(),
+ 'tokenEndpoint' => $this->getTokenEndpoint(),
+ 'introspectionEndpoint' => $this->getIntrospectionEndpoint(),
+ 'scope' => $this->getScope(),
+ ];
+ }
+}
diff --git a/lib/Db/OidcProviderMapper.php b/lib/Db/OidcProviderMapper.php
new file mode 100644
index 0000000000..e8fb68e1c7
--- /dev/null
+++ b/lib/Db/OidcProviderMapper.php
@@ -0,0 +1,143 @@
+
+ */
+class OidcProviderMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'mail_oidc_providers');
+ }
+
+ /**
+ * @return OidcProvider[]
+ */
+ public function getAll(): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->orderBy('email_domain', 'asc');
+ return $this->findEntities($qb);
+ }
+
+ public function get(int $id): ?OidcProvider {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Find the provider responsible for the given email domain (case-insensitive), if any.
+ */
+ public function findByEmailDomain(string $emailDomain): ?OidcProvider {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq(
+ $qb->func()->lower('email_domain'),
+ $qb->createNamedParameter(mb_strtolower($emailDomain)),
+ ));
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Build (but do not persist) an OidcProvider from admin form data.
+ *
+ * @throws ValidationException
+ */
+ public function validate(array $data): OidcProvider {
+ $exception = new ValidationException();
+
+ $requiredStrings = [
+ 'name', 'emailDomain', 'imapHost', 'imapSslMode',
+ 'smtpHost', 'smtpSslMode', 'clientId',
+ ];
+ foreach ($requiredStrings as $field) {
+ if (!isset($data[$field]) || $data[$field] === '') {
+ $exception->setField($field, false);
+ }
+ }
+ if (!isset($data['imapPort']) || (int)$data['imapPort'] === 0) {
+ $exception->setField('imapPort', false);
+ }
+ if (!isset($data['smtpPort']) || (int)$data['smtpPort'] === 0) {
+ $exception->setField('smtpPort', false);
+ }
+
+ // Endpoints come either from the discovery document or are entered manually.
+ $manualEndpoints = !empty($data['manualEndpoints']);
+ if ($manualEndpoints) {
+ foreach (['authorizationEndpoint', 'tokenEndpoint'] as $field) {
+ if (!isset($data[$field]) || $data[$field] === '') {
+ $exception->setField($field, false);
+ }
+ }
+ } elseif (!isset($data['discoveryUrl']) || $data['discoveryUrl'] === '') {
+ $exception->setField('discoveryUrl', false);
+ }
+
+ if (!empty($exception->getFields())) {
+ throw $exception;
+ }
+
+ $provider = new OidcProvider();
+ if (isset($data['id'])) {
+ $provider->setId((int)$data['id']);
+ }
+ $provider->setName($data['name']);
+ // Stored lower-cased so it lines up with the case-insensitive lookup and the
+ // unique index does not depend on the database collation.
+ $provider->setEmailDomain(mb_strtolower($data['emailDomain']));
+ $provider->setImapHost($data['imapHost']);
+ $provider->setImapPort((int)$data['imapPort']);
+ $provider->setImapSslMode($data['imapSslMode']);
+ $provider->setSmtpHost($data['smtpHost']);
+ $provider->setSmtpPort((int)$data['smtpPort']);
+ $provider->setSmtpSslMode($data['smtpSslMode']);
+ $provider->setClientId($data['clientId']);
+ $provider->setManualEndpoints($manualEndpoints);
+ $provider->setDiscoveryUrl($manualEndpoints ? '' : $data['discoveryUrl']);
+ $provider->setAuthorizationEndpoint($manualEndpoints ? $data['authorizationEndpoint'] : '');
+ $provider->setTokenEndpoint($manualEndpoints ? $data['tokenEndpoint'] : '');
+ // Optional even in manual mode: without it a rejected refresh can not be
+ // confirmed, so it is simply never treated as a dead grant.
+ $provider->setIntrospectionEndpoint(
+ $manualEndpoints ? ($data['introspectionEndpoint'] ?? '') : '',
+ );
+ $provider->setScope(
+ isset($data['scope']) && $data['scope'] !== ''
+ ? $data['scope']
+ : 'openid email offline_access',
+ );
+ // Only overwrite the secret when a real value (not the masked placeholder) is given
+ if (isset($data['clientSecret']) && $data['clientSecret'] !== OidcProvider::CLIENT_SECRET_PLACEHOLDER) {
+ $provider->setClientSecret($data['clientSecret']);
+ }
+
+ return $provider;
+ }
+}
diff --git a/lib/Exception/OidcProviderNotFoundException.php b/lib/Exception/OidcProviderNotFoundException.php
new file mode 100644
index 0000000000..00511c2637
--- /dev/null
+++ b/lib/Exception/OidcProviderNotFoundException.php
@@ -0,0 +1,10 @@
+discoveryCache = $cacheFactory->createDistributed(self::DISCOVERY_CACHE_PREFIX);
+ }
+
+ /**
+ * All configured providers, ordered by email domain. The client secret is
+ * never exposed — {@see OidcProvider::jsonSerialize()} masks it.
+ *
+ * @return OidcProvider[]
+ */
+ public function getProviders(): array {
+ return $this->providerMapper->getAll();
+ }
+
+ public function getProvider(int $id): ?OidcProvider {
+ return $this->providerMapper->get($id);
+ }
+
+ /**
+ * Build the IdP authorization-endpoint URL to start the interactive consent
+ * flow for a provider. The state is passed through and later validated by the
+ * redirect callback.
+ *
+ * @throws Exception when discovery can not be resolved
+ */
+ public function getAuthorizationUrl(OidcProvider $provider, string $state): string {
+ $authorizationEndpoint = $this->getEndpoints($provider)['authorization_endpoint'];
+ $query = http_build_query([
+ 'client_id' => $provider->getClientId(),
+ 'redirect_uri' => $this->getRedirectUrl(),
+ 'response_type' => 'code',
+ 'response_mode' => 'query',
+ 'prompt' => 'consent',
+ 'scope' => $provider->getScope(),
+ 'state' => $state,
+ ]);
+ $separator = str_contains($authorizationEndpoint, '?') ? '&' : '?';
+ return $authorizationEndpoint . $separator . $query;
+ }
+
+ /**
+ * Persist a new provider from admin form data, encrypting the client secret.
+ *
+ * @throws \OCA\Mail\Exception\ValidationException
+ */
+ public function createProvider(array $data): OidcProvider {
+ unset($data['id']);
+ $provider = $this->providerMapper->validate($data);
+ $this->encryptClientSecret($provider);
+ return $this->providerMapper->insert($provider);
+ }
+
+ /**
+ * Update an existing provider. When the submitted secret is the masking
+ * placeholder, {@see OidcProviderMapper::validate()} leaves it untouched so the
+ * stored secret is preserved.
+ *
+ * @param array $data
+ * @return OidcProvider
+ * @throws OidcProviderNotFoundException
+ * @throws ValidationException
+ * @throws \OCP\DB\Exception
+ */
+ public function updateProvider(array $data): OidcProvider {
+ $provider = $this->providerMapper->validate($data);
+ if ($provider->getId() === null) {
+ throw new OidcProviderNotFoundException('Can not update a provider without an id');
+ }
+ $this->encryptClientSecret($provider);
+ return $this->providerMapper->update($provider);
+ }
+
+ public function deleteProvider(int $id): void {
+ $provider = $this->providerMapper->get($id);
+ if ($provider !== null) {
+ $this->providerMapper->delete($provider);
+ }
+ }
+
+ /**
+ * Encrypt the plaintext client secret set by validation, if one was supplied.
+ */
+ private function encryptClientSecret(OidcProvider $provider): void {
+ $secret = $provider->getClientSecret();
+ if ($secret !== null && $secret !== '') {
+ $provider->setClientSecret($this->crypto->encrypt($secret));
+ }
+ }
+
+ /**
+ * Extract the domain part of the account's email address, lower-cased.
+ */
+ private function getEmailDomain(Account $account): ?string {
+ $email = $account->getEmail();
+ $atPos = strrpos($email, '@');
+ if ($atPos === false || $atPos === strlen($email) - 1) {
+ return null;
+ }
+ return mb_strtolower(substr($email, $atPos + 1));
+ }
+
+ /**
+ * Resolve the admin-configured provider responsible for this account's
+ * email domain, if any.
+ */
+ public function getProviderForAccount(Account $account): ?OidcProvider {
+ $domain = $this->getEmailDomain($account);
+ if ($domain === null) {
+ return null;
+ }
+ return $this->providerMapper->findByEmailDomain($domain);
+ }
+
+ /**
+ * Whether this account authenticates over XOAUTH2 against a configured OIDC provider.
+ */
+ public function isOidcAccount(Account $account): bool {
+ if ($account->getMailAccount()->getAuthMethod() !== 'xoauth2') {
+ return false;
+ }
+ return $this->getProviderForAccount($account) !== null;
+ }
+
+ /**
+ * Fetch and cache the provider's OpenID Connect discovery document. The returned
+ * array is guaranteed to contain non-empty `authorization_endpoint` and
+ * `token_endpoint` entries.
+ *
+ * @return array
+ * @throws Exception when the document can not be fetched or is missing endpoints
+ */
+ public function getDiscovery(OidcProvider $provider): array {
+ $discoveryUrl = $provider->getDiscoveryUrl();
+ $cached = $this->discoveryCache->get($discoveryUrl);
+ if (is_string($cached)) {
+ /** @var array $data */
+ $data = json_decode($cached, true, 512, JSON_THROW_ON_ERROR);
+ return $data;
+ }
+
+ $httpClient = $this->clientService->newClient();
+ $response = $httpClient->get($discoveryUrl, [
+ 'headers' => ['Accept' => 'application/json'],
+ ]);
+ /** @var array $data */
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+
+ if (empty($data['authorization_endpoint']) || empty($data['token_endpoint'])) {
+ throw new ServiceException('OIDC discovery document for ' . $discoveryUrl . ' is missing required endpoints');
+ }
+
+ $this->discoveryCache->set($discoveryUrl, json_encode($data, JSON_THROW_ON_ERROR), self::DISCOVERY_CACHE_TTL);
+ return $data;
+ }
+
+ /**
+ * Resolve the provider's authorization and token endpoints, either from the
+ * admin-entered manual values or from its discovery document.
+ *
+ * The introspection endpoint is optional: with manual endpoints it is only known
+ * when the admin entered one. When it is null a rejected refresh can not be
+ * confirmed and is therefore never treated as a dead grant.
+ *
+ * @return array{authorization_endpoint: string, token_endpoint: string, introspection_endpoint: ?string}
+ * @throws Exception when discovery is needed but can not be resolved
+ */
+ public function getEndpoints(OidcProvider $provider): array {
+ if ($provider->getManualEndpoints()) {
+ $introspection = $provider->getIntrospectionEndpoint();
+ return [
+ 'authorization_endpoint' => $provider->getAuthorizationEndpoint(),
+ 'token_endpoint' => $provider->getTokenEndpoint(),
+ 'introspection_endpoint' => $introspection !== '' ? $introspection : null,
+ ];
+ }
+
+ $discovery = $this->getDiscovery($provider);
+ return [
+ 'authorization_endpoint' => (string)$discovery['authorization_endpoint'],
+ 'token_endpoint' => (string)$discovery['token_endpoint'],
+ 'introspection_endpoint' => isset($discovery['introspection_endpoint'])
+ ? (string)$discovery['introspection_endpoint']
+ : null,
+ ];
+ }
+
+ /**
+ * Ask the provider whether a token is still active (RFC 7662).
+ *
+ * @return bool|null true/false when the provider answered, null when introspection
+ * is unavailable or failed — the caller must not treat that as
+ * proof the token is dead.
+ */
+ public function introspectToken(OidcProvider $provider, string $token, string $hint): ?bool {
+ try {
+ $endpoint = $this->getEndpoints($provider)['introspection_endpoint'];
+ } catch (Exception $e) {
+ return null;
+ }
+ if ($endpoint === null) {
+ return null;
+ }
+
+ $body = [
+ 'token' => $token,
+ 'token_type_hint' => $hint,
+ 'client_id' => $provider->getClientId(),
+ ];
+ $clientSecret = $this->getClientSecret($provider);
+ if ($clientSecret !== null) {
+ $body['client_secret'] = $clientSecret;
+ }
+
+ try {
+ $response = $this->clientService->newClient()->post($endpoint, [
+ 'headers' => ['Accept' => 'application/json'],
+ 'body' => $body,
+ ]);
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (Exception $e) {
+ $this->logger->warning('OIDC introspection failed for provider {providerId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'providerId' => $provider->getId(),
+ ]);
+ return null;
+ }
+
+ return isset($data['active']) ? (bool)$data['active'] : null;
+ }
+
+ /**
+ * Exchange an authorization code for tokens and store them on the account.
+ *
+ * @param string $code the authorization code returned to the redirect URI
+ * @param string|null $codeVerifier the PKCE verifier, when PKCE was used
+ */
+ public function finishConnect(OidcProvider $provider, Account $account, string $code, ?string $codeVerifier = null): Account {
+ try {
+ $tokenEndpoint = $this->getEndpoints($provider)['token_endpoint'];
+ } catch (Exception $e) {
+ $this->logger->error('Could not resolve OIDC endpoints for provider {providerId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'providerId' => $provider->getId(),
+ ]);
+ return $account;
+ }
+
+ $body = [
+ 'client_id' => $provider->getClientId(),
+ 'grant_type' => 'authorization_code',
+ 'redirect_uri' => $this->getRedirectUrl(),
+ 'code' => $code,
+ ];
+ $clientSecret = $this->getClientSecret($provider);
+ if ($clientSecret !== null) {
+ $body['client_secret'] = $clientSecret;
+ }
+ if ($codeVerifier !== null) {
+ $body['code_verifier'] = $codeVerifier;
+ }
+
+ $httpClient = $this->clientService->newClient();
+ try {
+ $response = $httpClient->post($tokenEndpoint, [
+ 'headers' => ['Accept' => 'application/json'],
+ 'body' => $body,
+ ]);
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (Exception $e) {
+ $this->logger->error('Could not link OIDC account: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return $account;
+ }
+
+ if (!isset($data['access_token'], $data['expires_in'])) {
+ $this->logger->error('OIDC token endpoint returned an unexpected response while linking account {accountId}', [
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ if (isset($data['refresh_token'])) {
+ $account->getMailAccount()->setOauthRefreshToken($this->crypto->encrypt($data['refresh_token']));
+ }
+ $account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($data['access_token']));
+ $account->getMailAccount()->setOauthTokenTtl($this->timeFactory->getTime() + $data['expires_in']);
+ $account->getMailAccount()->setOauthNeedsReauth(false);
+ return $account;
+ }
+
+ /**
+ * Refresh the account's access token using the stored refresh token, if it is
+ * about to expire. Safe to call outside a user session (cron).
+ */
+ public function refresh(Account $account): Account {
+ $oauthRefreshToken = $account->getMailAccount()->getOauthRefreshToken();
+ if ($account->getMailAccount()->getOauthTokenTtl() === null) {
+ // Account is not authorized yet
+ return $account;
+ }
+
+ // Only refresh if the token expires in the next minute
+ if ($this->timeFactory->getTime() <= ($account->getMailAccount()->getOauthTokenTtl() - 60)) {
+ // No need to refresh yet
+ return $account;
+ }
+
+ $provider = $this->getProviderForAccount($account);
+ if ($provider === null) {
+ $this->logger->warning('Can not refresh OIDC token for account {accountId}: no provider matches its email domain', [
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ if ($oauthRefreshToken === null) {
+ // The access token expired and the provider never issued a refresh token
+ // (e.g. offline_access was not granted). Only the user can fix this.
+ $this->logger->info('OIDC account {accountId} has no refresh token, re-authentication required', [
+ 'accountId' => $account->getId(),
+ ]);
+ $account->getMailAccount()->setOauthNeedsReauth(true);
+ return $account;
+ }
+
+ try {
+ $refreshToken = $this->crypto->decrypt($oauthRefreshToken);
+ } catch (Exception $e) {
+ // A stored refresh token that no longer decrypts is unusable (e.g. the server
+ // secret was rotated). Only the user can fix it, and this must not bubble up
+ // and break opening the mailbox.
+ $this->logger->warning('Could not decrypt refresh token for account {accountId}, re-authentication required: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'accountId' => $account->getId(),
+ ]);
+ $account->getMailAccount()->setOauthNeedsReauth(true);
+ return $account;
+ }
+
+ try {
+ $tokenEndpoint = $this->getEndpoints($provider)['token_endpoint'];
+ } catch (Exception $e) {
+ $this->logger->warning('Could not resolve OIDC endpoints for provider {providerId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'providerId' => $provider->getId(),
+ ]);
+ return $account;
+ }
+
+ $body = [
+ 'client_id' => $provider->getClientId(),
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $refreshToken,
+ ];
+ $clientSecret = $this->getClientSecret($provider);
+ if ($clientSecret !== null) {
+ $body['client_secret'] = $clientSecret;
+ }
+
+ $httpClient = $this->clientService->newClient();
+ try {
+ $response = $httpClient->post($tokenEndpoint, [
+ 'headers' => ['Accept' => 'application/json'],
+ 'body' => $body,
+ ]);
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (Exception $e) {
+ $this->logger->warning('Could not refresh OIDC token for account {accountId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'accountId' => $account->getId(),
+ ]);
+
+ // The refresh may have failed because the provider was unreachable rather
+ // than because the grant is gone. Ask the provider directly; only a
+ // definitive "inactive" answer marks the account as needing re-auth.
+ if ($this->introspectToken($provider, $refreshToken, 'refresh_token') === false) {
+ $this->logger->info('OIDC refresh token for account {accountId} is no longer active, re-authentication required', [
+ 'accountId' => $account->getId(),
+ ]);
+ $account->getMailAccount()->setOauthNeedsReauth(true);
+ }
+ return $account;
+ }
+
+ if (!isset($data['access_token'], $data['expires_in'])) {
+ $this->logger->warning('OIDC token refresh for account {accountId} returned an unexpected response', [
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ $account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($data['access_token']));
+ $account->getMailAccount()->setOauthTokenTtl($this->timeFactory->getTime() + $data['expires_in']);
+ $account->getMailAccount()->setOauthNeedsReauth(false);
+ // Providers may rotate refresh tokens
+ if (isset($data['refresh_token'])) {
+ $account->getMailAccount()->setOauthRefreshToken($this->crypto->encrypt($data['refresh_token']));
+ }
+
+ return $account;
+ }
+
+ /**
+ * Decrypt the provider's stored client secret, if one is set.
+ */
+ private function getClientSecret(OidcProvider $provider): ?string {
+ $encrypted = $provider->getClientSecret();
+ if ($encrypted === null || $encrypted === '') {
+ return null;
+ }
+ return $this->crypto->decrypt($encrypted);
+ }
+
+ public function getRedirectUrl(): string {
+ return $this->urlGenerator->linkToRouteAbsolute('mail.oidcIntegration.oauthRedirect');
+ }
+}
diff --git a/lib/Listener/OauthTokenRefreshListener.php b/lib/Listener/OauthTokenRefreshListener.php
index 7e705e9472..cea960012c 100644
--- a/lib/Listener/OauthTokenRefreshListener.php
+++ b/lib/Listener/OauthTokenRefreshListener.php
@@ -12,6 +12,7 @@
use OCA\Mail\Events\BeforeImapClientCreated;
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;
@@ -23,6 +24,7 @@ class OauthTokenRefreshListener implements IEventListener {
public function __construct(
private GoogleIntegration $googleIntegration,
private MicrosoftIntegration $microsoftIntegration,
+ private OidcIntegration $oidcIntegration,
private AccountService $accountService,
) {
}
@@ -36,6 +38,8 @@ public function handle(Event $event): void {
$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/Version5110Date20260717120000.php b/lib/Migration/Version5110Date20260717120000.php
new file mode 100644
index 0000000000..ded3a7d407
--- /dev/null
+++ b/lib/Migration/Version5110Date20260717120000.php
@@ -0,0 +1,134 @@
+hasTable('mail_oidc_providers')) {
+ $table = $schema->createTable('mail_oidc_providers');
+ $table->addColumn('id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ ]);
+ $table->addColumn('name', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => '',
+ ]);
+ $table->addColumn('email_domain', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => '',
+ ]);
+ $table->addColumn('imap_host', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => '',
+ ]);
+ $table->addColumn('imap_port', Types::SMALLINT, [
+ 'notnull' => true,
+ 'unsigned' => true,
+ 'default' => 993,
+ ]);
+ $table->addColumn('imap_ssl_mode', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ 'default' => 'ssl',
+ ]);
+ $table->addColumn('smtp_host', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => '',
+ ]);
+ $table->addColumn('smtp_port', Types::SMALLINT, [
+ 'notnull' => true,
+ 'unsigned' => true,
+ 'default' => 587,
+ ]);
+ $table->addColumn('smtp_ssl_mode', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ 'default' => 'tls',
+ ]);
+ $table->addColumn('client_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => '',
+ ]);
+ $table->addColumn('client_secret', Types::TEXT, [
+ 'notnull' => false,
+ ]);
+ $table->addColumn('discovery_url', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 2048,
+ 'default' => '',
+ ]);
+ $table->addColumn('manual_endpoints', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false,
+ ]);
+ $table->addColumn('authorization_endpoint', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 2048,
+ 'default' => '',
+ ]);
+ $table->addColumn('token_endpoint', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 2048,
+ 'default' => '',
+ ]);
+ $table->addColumn('introspection_endpoint', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 2048,
+ 'default' => '',
+ ]);
+ $table->addColumn('scope', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => 'openid email offline_access',
+ ]);
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['email_domain'], 'mail_oidc_dm_idx');
+ }
+
+ // Set when the stored OIDC grant can no longer be renewed (refresh token
+ // expired/revoked, or none was ever issued) so the client can prompt the user
+ // to re-authenticate instead of failing silently.
+ $accounts = $schema->getTable('mail_accounts');
+ if (!$accounts->hasColumn('oauth_needs_reauth')) {
+ $accounts->addColumn('oauth_needs_reauth', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false,
+ ]);
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php
index d7658b1b1b..84c2843dc6 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,
@@ -90,6 +92,14 @@ public function getForm() {
'importance_classification_default',
$this->classificationSettingsService->isClassificationEnabledByDefault(),
);
+ $this->initialStateService->provideInitialState(
+ 'oidc_providers',
+ $this->oidcIntegration->getProviders(),
+ );
+ $this->initialStateService->provideInitialState(
+ 'oidc_redirect_url',
+ $this->oidcIntegration->getRedirectUrl(),
+ );
$this->initialStateService->provideInitialState(
'microsoft_oauth_tenant_id',
$this->microsoftIntegration->getTenantId(),
diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue
index ac37be97f6..09e65bc119 100644
--- a/src/components/AccountForm.vue
+++ b/src/components/AccountForm.vue
@@ -30,6 +30,7 @@
{{ t('mail', 'Please enter an email of the format name@example.com') }}
{{ t('mail', 'Microsoft requires OAuth authentication. Ask your Nextcloud admin to configure Microsoft OAuth in the admin settings.') }}
+
+ {{ t('mail', 'This email domain uses single sign-on. You will be redirected to your identity provider to grant access to your mail account.') }}
+
{{ t('mail', 'Connection failed. Please verify your information and try again') }}
{{ t('mail', 'Change password') }}
+ :aria-label="accountFixLabel(group.account)"
+ variant="tertiary">{{ accountFixLabel(group.account) }}
@@ -165,6 +165,19 @@ export default {
},
methods: {
+ /**
+ * A broken OAuth account has no password to change — it has to be reconnected
+ * at the identity provider instead.
+ *
+ * @param {object} account the account the error belongs to
+ * @return {string}
+ */
+ accountFixLabel(account) {
+ return account.authMethod === 'xoauth2'
+ ? t('mail', 'Reconnect to Mailbox')
+ : t('mail', 'Change password')
+ },
+
showMailSettings() {
this.showSettings = true
},
diff --git a/src/components/OidcReauthDialog.vue b/src/components/OidcReauthDialog.vue
new file mode 100644
index 0000000000..2a8658ed54
--- /dev/null
+++ b/src/components/OidcReauthDialog.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+ {{
+ t(
+ 'mail',
+ 'The sign-in for {email} expired and could not be renewed automatically. Reconnect to keep this account in sync.',
+ { email: account.emailAddress },
+ )
+ }}
+
+
+
+
+
diff --git a/src/components/settings/AdminSettings.vue b/src/components/settings/AdminSettings.vue
index 683154a279..1b6b22ed0f 100644
--- a/src/components/settings/AdminSettings.vue
+++ b/src/components/settings/AdminSettings.vue
@@ -245,6 +245,12 @@
+
+
+ {{ t('mail', 'OpenID Connect integration') }}
+
+
+
{{ t('mail', 'User Interface Preference Defaults') }}
@@ -291,6 +297,7 @@ import IconAdd from 'vue-material-design-icons/Plus.vue'
import AntiSpamSettings from './AntiSpamSettings.vue'
import GmailAdminOauthSettings from './GmailAdminOauthSettings.vue'
import MicrosoftAdminOauthSettings from './MicrosoftAdminOauthSettings.vue'
+import OidcAdminSettings from './OidcAdminSettings.vue'
import ProvisioningSettings from './ProvisioningSettings.vue'
import logger from '../../logger.js'
import {
@@ -317,6 +324,7 @@ export default {
GmailAdminOauthSettings,
AntiSpamSettings,
MicrosoftAdminOauthSettings,
+ OidcAdminSettings,
ProvisioningSettings,
SettingsSection,
ButtonVue,
diff --git a/src/components/settings/OidcAdminSettings.vue b/src/components/settings/OidcAdminSettings.vue
new file mode 100644
index 0000000000..a6a58a4f4f
--- /dev/null
+++ b/src/components/settings/OidcAdminSettings.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+ {{
+ t(
+ 'mail',
+ 'Configure OpenID Connect providers so users whose email domain matches can sign in to their mail account with your identity provider (XOAUTH2), instead of storing a password.',
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail', 'Add OIDC provider') }}
+
+
+
+
+
+
+
diff --git a/src/components/settings/OidcProviderForm.vue b/src/components/settings/OidcProviderForm.vue
new file mode 100644
index 0000000000..9e5210069f
--- /dev/null
+++ b/src/components/settings/OidcProviderForm.vue
@@ -0,0 +1,344 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/service/OidcIntegrationService.js b/src/service/OidcIntegrationService.js
new file mode 100644
index 0000000000..c4721c4955
--- /dev/null
+++ b/src/service/OidcIntegrationService.js
@@ -0,0 +1,31 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+
+export async function getOidcProviders() {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers')
+ return (await axios.get(url)).data
+}
+
+export async function createOidcProvider(provider) {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers')
+ return (await axios.post(url, { data: provider })).data
+}
+
+export async function updateOidcProvider(provider) {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers/{id}', {
+ id: provider.id,
+ })
+ return (await axios.put(url, { data: provider })).data
+}
+
+export async function deleteOidcProvider(id) {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers/{id}', {
+ id,
+ })
+ return (await axios.delete(url)).data
+}
diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js
index 825551d174..7b6fc0ac93 100644
--- a/src/store/mainStore/actions.js
+++ b/src/store/mainStore/actions.js
@@ -239,6 +239,19 @@ export default function mainStoreActions() {
return account
})
},
+ async checkOidcReauth(account) {
+ if (!account || account.authMethod !== 'xoauth2' || account.oauthNeedsReauth) {
+ return
+ }
+ try {
+ const fresh = await fetchAccount(account.id)
+ if (fresh.oauthNeedsReauth) {
+ this.patchAccountMutation({ account, data: { oauthNeedsReauth: true } })
+ }
+ } catch (error) {
+ logger.error('Could not check OIDC re-authentication state', { error })
+ }
+ },
async startAccountSetup(config) {
const account = await createAccount(config)
logger.debug(`account ${account.id} created`, { account })
diff --git a/src/util/oidc.js b/src/util/oidc.js
new file mode 100644
index 0000000000..540d0d53a9
--- /dev/null
+++ b/src/util/oidc.js
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+import { getUserConsent } from '../integration/oauth.js'
+import { generateOauthState } from '../service/OauthStateService.js'
+
+/**
+ * The admin-configured OIDC providers passed to the page as initial state.
+ *
+ * @return {object[]}
+ */
+export function loadOidcProviders() {
+ return loadState('mail', 'oidc_providers', [])
+}
+
+/**
+ * Find the provider whose email domain matches the given address, if any.
+ *
+ * @param {string} email the email address to match
+ * @param {object[]} providers the configured providers
+ * @return {?object} the matching provider or null
+ */
+export function findProviderForEmail(email, providers) {
+ const at = (email ?? '').lastIndexOf('@')
+ if (at === -1) {
+ return null
+ }
+ const domain = email.slice(at + 1).toLowerCase()
+ return providers.find((provider) => (provider.emailDomain || '').toLowerCase() === domain) ?? null
+}
+
+/**
+ * Open the provider's consent popup for an account, binding a fresh CSRF state.
+ * Resolves when the popup reports success and rejects (with CONSENT_ABORTED) when
+ * the user closes it.
+ *
+ * @param {object} provider the matched provider
+ * @param {number} accountId the account being (re)connected
+ * @return {Promise}
+ */
+export async function openOidcConsent(provider, accountId) {
+ const url = provider.authorizeUrl.replace('_state_', await generateOauthState(accountId))
+ await getUserConsent(url)
+}
diff --git a/src/views/Home.vue b/src/views/Home.vue
index 56fad9e960..33cd2b26e5 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -15,6 +15,8 @@
+
+
@@ -24,6 +26,7 @@ import { mapState, mapStores } from 'pinia'
import ComposerSessionIndicator from '../components/ComposerSessionIndicator.vue'
import MailboxThread from '../components/MailboxThread.vue'
import Navigation from '../components/Navigation.vue'
+import OidcReauthDialog from '../components/OidcReauthDialog.vue'
import Outbox from '../components/Outbox.vue'
import logger from '../logger.js'
import { testAccountConnection } from '../service/AccountService.js'
@@ -38,6 +41,7 @@ export default {
MailboxThread,
Navigation,
NewMessageModal: () => import(/* webpackChunkName: "new-message-modal" */ '../components/NewMessageModal.vue'),
+ OidcReauthDialog,
Outbox,
ComposerSessionIndicator,
},
@@ -90,6 +94,11 @@ export default {
account,
data: { connectionStatus: await testAccountConnection(account.accountId) },
})
+ // Testing the connection creates an IMAP client, which runs the token refresh
+ // on the server; that may flag the account for re-authentication (even when the
+ // still-valid access token means the test itself passed). Re-read the flag so
+ // the reconnect dialog can show. checkOidcReauth ignores non-OIDC accounts.
+ await this.mainStore.checkOidcReauth(account)
}
},
diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php
index b290978744..1d2f206bd0 100644
--- a/tests/Integration/Db/MailAccountTest.php
+++ b/tests/Integration/Db/MailAccountTest.php
@@ -76,6 +76,7 @@ public function testToAPI() {
'imipCreate' => false,
'protocol' => 'imap',
'path' => null,
+ 'oauthNeedsReauth' => false,
], $a->toJson());
}
@@ -119,6 +120,7 @@ public function testMailAccountConstruct() {
'imipCreate' => false,
'protocol' => 'imap',
'path' => null,
+ 'oauthNeedsReauth' => false,
];
$a = new MailAccount($expected);
// TODO: fix inconsistency
diff --git a/tests/Unit/Controller/OidcIntegrationControllerTest.php b/tests/Unit/Controller/OidcIntegrationControllerTest.php
new file mode 100644
index 0000000000..01353a779b
--- /dev/null
+++ b/tests/Unit/Controller/OidcIntegrationControllerTest.php
@@ -0,0 +1,240 @@
+oidcIntegration = $this->createMock(OidcIntegration::class);
+ $this->accountService = $this->createMock(AccountService::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->mailboxSync = $this->createMock(MailboxSync::class);
+ $this->oauthStateService = $this->createMock(OauthStateService::class);
+
+ $this->controller = new OidcIntegrationController(
+ $this->createMock(IRequest::class),
+ 'alice',
+ $this->oidcIntegration,
+ $this->accountService,
+ $this->logger,
+ $this->mailboxSync,
+ $this->oauthStateService,
+ );
+ }
+
+ public function testIndexReturnsProviders(): void {
+ $provider = new OidcProvider();
+ $this->oidcIntegration->method('getProviders')->willReturn([$provider]);
+
+ $response = $this->controller->index();
+
+ $this->assertInstanceOf(JSONResponse::class, $response);
+ $this->assertSame([$provider], $response->getData());
+ }
+
+ public function testCreateSuccess(): void {
+ $provider = new OidcProvider();
+ $this->oidcIntegration->expects($this->once())
+ ->method('createProvider')
+ ->with(['name' => 'Keycloak'])
+ ->willReturn($provider);
+
+ $response = $this->controller->create(['name' => 'Keycloak']);
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertSame($provider, $response->getData());
+ }
+
+ public function testCreateValidationFailure(): void {
+ $exception = new ValidationException();
+ $exception->setField('name', false);
+ $this->oidcIntegration->method('createProvider')->willThrowException($exception);
+
+ $response = $this->controller->create([]);
+
+ $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus());
+ }
+
+ /**
+ * Anything that is not a validation error is left to the TrapError middleware.
+ */
+ public function testCreatePropagatesUnexpectedErrors(): void {
+ $this->oidcIntegration->method('createProvider')
+ ->willThrowException(new \RuntimeException('db down'));
+
+ $this->expectException(\RuntimeException::class);
+
+ $this->controller->create(['name' => 'x']);
+ }
+
+ public function testUpdatePropagatesUnexpectedErrors(): void {
+ $this->oidcIntegration->method('updateProvider')
+ ->willThrowException(new \RuntimeException('db down'));
+
+ $this->expectException(\RuntimeException::class);
+
+ $this->controller->update(1, ['name' => 'x']);
+ }
+
+ public function testUpdateValidationFailure(): void {
+ $exception = new ValidationException();
+ $exception->setField('emailDomain', false);
+ $this->oidcIntegration->method('updateProvider')->willThrowException($exception);
+
+ $response = $this->controller->update(1, []);
+
+ $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus());
+ }
+
+ public function testUpdateMergesId(): void {
+ $provider = new OidcProvider();
+ $this->oidcIntegration->expects($this->once())
+ ->method('updateProvider')
+ ->with(['name' => 'Keycloak', 'id' => 5])
+ ->willReturn($provider);
+
+ $response = $this->controller->update(5, ['name' => 'Keycloak']);
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ }
+
+ public function testDestroyDeletesProvider(): void {
+ $this->oidcIntegration->expects($this->once())->method('deleteProvider')->with(3);
+
+ $response = $this->controller->destroy(3);
+
+ $this->assertInstanceOf(JSONResponse::class, $response);
+ }
+
+ public function testAuthorizeRedirectsToProvider(): void {
+ $provider = new OidcProvider();
+ $this->oidcIntegration->method('getProvider')->with(7)->willReturn($provider);
+ $this->oidcIntegration->method('getAuthorizationUrl')
+ ->with($provider, 'the-state')
+ ->willReturn('https://idp.example.com/auth?state=the-state');
+
+ $response = $this->controller->authorize(7, 'the-state');
+
+ $this->assertInstanceOf(RedirectResponse::class, $response);
+ $this->assertSame('https://idp.example.com/auth?state=the-state', $response->getRedirectURL());
+ }
+
+ public function testAuthorizeUnknownProviderReturnsDone(): void {
+ $this->oidcIntegration->method('getProvider')->willReturn(null);
+ $this->oidcIntegration->expects($this->never())->method('getAuthorizationUrl');
+
+ $response = $this->controller->authorize(99, 'the-state');
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+
+ public function testAuthorizeDiscoveryFailureReturnsDone(): void {
+ $provider = new OidcProvider();
+ $this->oidcIntegration->method('getProvider')->willReturn($provider);
+ $this->oidcIntegration->method('getAuthorizationUrl')
+ ->willThrowException(new ServiceException('discovery down'));
+
+ $response = $this->controller->authorize(7, 'the-state');
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+
+ public function testOauthRedirectWithoutCodeReturnsDone(): void {
+ $this->oauthStateService->expects($this->never())->method('validateAndConsume');
+
+ $response = $this->controller->oauthRedirect(null, null, null);
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+
+ public function testOauthRedirectInvalidStateReturnsDone(): void {
+ $this->oauthStateService->method('validateAndConsume')
+ ->willThrowException(new InvalidOauthStateException());
+
+ $response = $this->controller->oauthRedirect('the-code', 'bad-state', null);
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+
+ public function testOauthRedirectNoProviderReturnsDone(): void {
+ $account = new Account(new MailAccount());
+ $this->oauthStateService->method('validateAndConsume')->willReturn(42);
+ $this->accountService->method('find')->with('alice', 42)->willReturn($account);
+ $this->oidcIntegration->method('getProviderForAccount')->willReturn(null);
+ $this->oidcIntegration->expects($this->never())->method('finishConnect');
+
+ $response = $this->controller->oauthRedirect('the-code', 'good-state', null);
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+
+ public function testOauthRedirectSurvivesSyncFailure(): void {
+ $mailAccount = new MailAccount();
+ $account = new Account($mailAccount);
+ $provider = new OidcProvider();
+ $this->oauthStateService->method('validateAndConsume')->willReturn(42);
+ $this->accountService->method('find')->willReturn($account);
+ $this->oidcIntegration->method('getProviderForAccount')->willReturn($provider);
+ $this->oidcIntegration->method('finishConnect')->willReturn($account);
+ $this->mailboxSync->method('sync')
+ ->willThrowException(new ServiceException('sync failed'));
+
+ $response = $this->controller->oauthRedirect('the-code', 'good-state', null);
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+
+ public function testOauthRedirectHappyPathStoresAndSyncs(): void {
+ $mailAccount = new MailAccount();
+ $account = new Account($mailAccount);
+ $provider = new OidcProvider();
+ $this->oauthStateService->method('validateAndConsume')->willReturn(42);
+ $this->accountService->method('find')->with('alice', 42)->willReturn($account);
+ $this->oidcIntegration->method('getProviderForAccount')->willReturn($provider);
+ $this->oidcIntegration->expects($this->once())
+ ->method('finishConnect')
+ ->with($provider, $account, 'the-code')
+ ->willReturn($account);
+ $this->accountService->expects($this->once())->method('update')->with($mailAccount);
+ $this->mailboxSync->expects($this->once())->method('sync')->with($account, $this->logger);
+
+ $response = $this->controller->oauthRedirect('the-code', 'good-state', null);
+
+ $this->assertInstanceOf(StandaloneTemplateResponse::class, $response);
+ }
+}
diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php
index 9b84226810..d29817d08d 100644
--- a/tests/Unit/Controller/PageControllerTest.php
+++ b/tests/Unit/Controller/PageControllerTest.php
@@ -15,7 +15,9 @@
use OCA\Mail\Contracts\IUserPreferences;
use OCA\Mail\Controller\PageController;
use OCA\Mail\Db\Mailbox;
+use OCA\Mail\Db\OidcProvider;
use OCA\Mail\Db\TagMapper;
+use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\AliasesService;
@@ -116,6 +118,8 @@ class PageControllerTest extends TestCase {
private ContextChatSettingsService $contextChatSettingsService;
private ClassificationSettingsService|MockObject $classificationSettingsService;
+
+ private OidcIntegration|MockObject $oidcIntegration;
protected function setUp(): void {
parent::setUp();
@@ -144,6 +148,7 @@ protected function setUp(): void {
$this->appManager = $this->createMock(IAppManager::class);
$this->appManager->method('getAppVersion')->willReturn('0.0.1-dev.0');
$this->contextChatSettingsService = $this->createMock(ContextChatSettingsService::class);
+ $this->oidcIntegration = $this->createMock(OidcIntegration::class);
$this->contextChatSettingsService->method('isIndexingEnabled')->willReturn(true);
$this->classificationSettingsService = $this->createMock(ClassificationSettingsService::class);
@@ -172,7 +177,8 @@ protected function setUp(): void {
$this->quickActionsService,
$this->appManager,
$this->contextChatSettingsService,
- $this->classificationSettingsService
+ $this->classificationSettingsService,
+ $this->oidcIntegration,
);
}
@@ -180,6 +186,17 @@ public function testIndex(): void {
$account1 = $this->createMock(Account::class);
$account2 = $this->createMock(Account::class);
$mailbox = $this->createStub(Mailbox::class);
+ $oidcProvider = new OidcProvider();
+ $oidcProvider->setId(7);
+ $oidcProvider->setName('IdP');
+ $oidcProvider->setEmailDomain('example.com');
+ $oidcProvider->setImapHost('imap.example.com');
+ $oidcProvider->setImapPort(993);
+ $oidcProvider->setImapSslMode('ssl');
+ $oidcProvider->setSmtpHost('smtp.example.com');
+ $oidcProvider->setSmtpPort(587);
+ $oidcProvider->setSmtpSslMode('tls');
+ $this->oidcIntegration->method('getProviders')->willReturn([$oidcProvider]);
$this->preferences->expects($this->exactly(14))
->method('getPreference')
->willReturnMap([
@@ -332,7 +349,7 @@ public function testIndex(): void {
$this->classificationSettingsService->expects(($this->once()))
->method(('isClassificationEnabledByDefault'))
->willReturn(true);
- $this->initialState->expects($this->exactly(27))
+ $this->initialState->expects($this->exactly(28))
->method('provideInitialState')
->withConsecutive(
['debug', true],
@@ -366,6 +383,7 @@ public function testIndex(): void {
['prefill_email', 'jane@doe.cz'],
['outbox-messages', []],
['quick-actions', []],
+ ['oidc_providers', $this->anything()],
['disable-scheduled-send', false],
['disable-snooze', false],
['allow-new-accounts', true],
diff --git a/tests/Unit/Db/OidcProviderMapperTest.php b/tests/Unit/Db/OidcProviderMapperTest.php
new file mode 100644
index 0000000000..3c15f01022
--- /dev/null
+++ b/tests/Unit/Db/OidcProviderMapperTest.php
@@ -0,0 +1,202 @@
+db = $this->createMock(IDBConnection::class);
+ $this->mapper = new OidcProviderMapper($this->db);
+ }
+
+ private function validData(): array {
+ return [
+ 'name' => 'Company Keycloak',
+ 'emailDomain' => 'example.com',
+ 'imapHost' => 'imap.example.com',
+ 'imapPort' => 993,
+ 'imapSslMode' => 'ssl',
+ 'smtpHost' => 'smtp.example.com',
+ 'smtpPort' => 587,
+ 'smtpSslMode' => 'tls',
+ 'clientId' => 'mail-client',
+ 'clientSecret' => 's3cret',
+ 'discoveryUrl' => 'https://idp.example.com/.well-known/openid-configuration',
+ ];
+ }
+
+ public function testValidateBuildsProvider(): void {
+ $provider = $this->mapper->validate($this->validData());
+
+ $this->assertInstanceOf(OidcProvider::class, $provider);
+ $this->assertSame('Company Keycloak', $provider->getName());
+ $this->assertSame('example.com', $provider->getEmailDomain());
+ $this->assertSame('imap.example.com', $provider->getImapHost());
+ $this->assertSame(993, $provider->getImapPort());
+ $this->assertSame('smtp.example.com', $provider->getSmtpHost());
+ $this->assertSame(587, $provider->getSmtpPort());
+ $this->assertSame('mail-client', $provider->getClientId());
+ $this->assertSame('s3cret', $provider->getClientSecret());
+ }
+
+ public function testValidateLowercasesEmailDomain(): void {
+ $data = $this->validData();
+ $data['emailDomain'] = 'Example.COM';
+
+ $provider = $this->mapper->validate($data);
+
+ $this->assertSame('example.com', $provider->getEmailDomain());
+ }
+
+ public function testValidateDefaultsScope(): void {
+ $provider = $this->mapper->validate($this->validData());
+
+ $this->assertSame('openid email offline_access', $provider->getScope());
+ }
+
+ public function testValidateKeepsExplicitScope(): void {
+ $data = $this->validData();
+ $data['scope'] = 'openid email';
+
+ $provider = $this->mapper->validate($data);
+
+ $this->assertSame('openid email', $provider->getScope());
+ }
+
+ public function testValidateSetsIdWhenGiven(): void {
+ $data = $this->validData();
+ $data['id'] = 7;
+
+ $provider = $this->mapper->validate($data);
+
+ $this->assertSame(7, $provider->getId());
+ }
+
+ public function testValidateCastsPortsToInt(): void {
+ $data = $this->validData();
+ $data['imapPort'] = '993';
+ $data['smtpPort'] = '587';
+
+ $provider = $this->mapper->validate($data);
+
+ $this->assertSame(993, $provider->getImapPort());
+ $this->assertSame(587, $provider->getSmtpPort());
+ }
+
+ public function testValidateIgnoresPlaceholderSecret(): void {
+ $data = $this->validData();
+ $data['clientSecret'] = OidcProvider::CLIENT_SECRET_PLACEHOLDER;
+
+ $provider = $this->mapper->validate($data);
+
+ $this->assertNull($provider->getClientSecret());
+ }
+
+ public function testValidateDefaultsToDiscoveryMode(): void {
+ $provider = $this->mapper->validate($this->validData());
+
+ $this->assertFalse($provider->getManualEndpoints());
+ $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $provider->getDiscoveryUrl());
+ $this->assertSame('', $provider->getAuthorizationEndpoint());
+ $this->assertSame('', $provider->getTokenEndpoint());
+ }
+
+ public function testValidateManualEndpoints(): void {
+ $data = $this->validData();
+ unset($data['discoveryUrl']);
+ $data['manualEndpoints'] = true;
+ $data['authorizationEndpoint'] = 'https://idp.example.com/authorize';
+ $data['tokenEndpoint'] = 'https://idp.example.com/token';
+
+ $provider = $this->mapper->validate($data);
+
+ $this->assertTrue($provider->getManualEndpoints());
+ $this->assertSame('https://idp.example.com/authorize', $provider->getAuthorizationEndpoint());
+ $this->assertSame('https://idp.example.com/token', $provider->getTokenEndpoint());
+ $this->assertSame('', $provider->getDiscoveryUrl());
+ }
+
+ public function testValidateManualEndpointsRequiresBothEndpoints(): void {
+ $data = $this->validData();
+ unset($data['discoveryUrl']);
+ $data['manualEndpoints'] = true;
+ $data['authorizationEndpoint'] = 'https://idp.example.com/authorize';
+
+ try {
+ $this->mapper->validate($data);
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('tokenEndpoint', $e->getFields());
+ $this->assertArrayNotHasKey('discoveryUrl', $e->getFields());
+ }
+ }
+
+ public function testValidateDiscoveryModeRequiresDiscoveryUrl(): void {
+ $data = $this->validData();
+ unset($data['discoveryUrl']);
+
+ try {
+ $this->mapper->validate($data);
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('discoveryUrl', $e->getFields());
+ }
+ }
+
+ public function testValidateRejectsMissingRequiredFields(): void {
+ $data = $this->validData();
+ unset($data['name'], $data['clientId']);
+
+ try {
+ $this->mapper->validate($data);
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('name', $e->getFields());
+ $this->assertArrayHasKey('clientId', $e->getFields());
+ }
+ }
+
+ public function testValidateRejectsEmptyString(): void {
+ $data = $this->validData();
+ $data['emailDomain'] = '';
+
+ try {
+ $this->mapper->validate($data);
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('emailDomain', $e->getFields());
+ }
+ }
+
+ public function testValidateRejectsZeroPorts(): void {
+ $data = $this->validData();
+ $data['imapPort'] = 0;
+ $data['smtpPort'] = 0;
+
+ try {
+ $this->mapper->validate($data);
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('imapPort', $e->getFields());
+ $this->assertArrayHasKey('smtpPort', $e->getFields());
+ }
+ }
+}
diff --git a/tests/Unit/Db/OidcProviderTest.php b/tests/Unit/Db/OidcProviderTest.php
new file mode 100644
index 0000000000..ec817a0f26
--- /dev/null
+++ b/tests/Unit/Db/OidcProviderTest.php
@@ -0,0 +1,76 @@
+setId(3);
+ $provider->setName('Company IdP');
+ $provider->setEmailDomain('example.com');
+ $provider->setImapHost('imap.example.com');
+ $provider->setImapPort(993);
+ $provider->setImapSslMode('ssl');
+ $provider->setSmtpHost('smtp.example.com');
+ $provider->setSmtpPort(587);
+ $provider->setSmtpSslMode('tls');
+ $provider->setClientId('mail');
+ $provider->setManualEndpoints(false);
+ $provider->setDiscoveryUrl('https://idp.example.com/.well-known/openid-configuration');
+ $provider->setAuthorizationEndpoint('');
+ $provider->setTokenEndpoint('');
+ $provider->setScope('openid email');
+ return $provider;
+ }
+
+ public function testJsonSerializeMasksSetSecret(): void {
+ $provider = $this->fullProvider();
+ $provider->setClientSecret('super-secret');
+
+ $json = $provider->jsonSerialize();
+
+ $this->assertSame(OidcProvider::CLIENT_SECRET_PLACEHOLDER, $json['clientSecret']);
+ }
+
+ public function testJsonSerializeNullSecretWhenUnset(): void {
+ $provider = $this->fullProvider();
+
+ $json = $provider->jsonSerialize();
+
+ $this->assertNull($json['clientSecret']);
+ }
+
+ public function testJsonSerializeExposesAllFields(): void {
+ $provider = $this->fullProvider();
+ $provider->setManualEndpoints(true);
+ $provider->setAuthorizationEndpoint('https://idp.example.com/authorize');
+ $provider->setTokenEndpoint('https://idp.example.com/token');
+
+ $json = $provider->jsonSerialize();
+
+ $this->assertSame(3, $json['id']);
+ $this->assertSame('Company IdP', $json['name']);
+ $this->assertSame('example.com', $json['emailDomain']);
+ $this->assertSame('imap.example.com', $json['imapHost']);
+ $this->assertSame(993, $json['imapPort']);
+ $this->assertSame('ssl', $json['imapSslMode']);
+ $this->assertSame('smtp.example.com', $json['smtpHost']);
+ $this->assertSame(587, $json['smtpPort']);
+ $this->assertSame('tls', $json['smtpSslMode']);
+ $this->assertSame('mail', $json['clientId']);
+ $this->assertTrue($json['manualEndpoints']);
+ $this->assertSame('https://idp.example.com/authorize', $json['authorizationEndpoint']);
+ $this->assertSame('https://idp.example.com/token', $json['tokenEndpoint']);
+ $this->assertSame('openid email', $json['scope']);
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcAccountMatchingTest.php b/tests/Unit/Integration/OidcIntegration/OidcAccountMatchingTest.php
new file mode 100644
index 0000000000..f3fdf6d696
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcAccountMatchingTest.php
@@ -0,0 +1,53 @@
+provider();
+ $this->providerMapper->expects($this->once())
+ ->method('findByEmailDomain')
+ ->with('example.com')
+ ->willReturn($provider);
+
+ $result = $this->integration->getProviderForAccount($this->account('alice@Example.com'));
+
+ $this->assertSame($provider, $result);
+ }
+
+ public function testGetProviderForAccountReturnsNullForInvalidEmail(): void {
+ $this->providerMapper->expects($this->never())->method('findByEmailDomain');
+
+ $result = $this->integration->getProviderForAccount($this->account('not-an-email'));
+
+ $this->assertNull($result);
+ }
+
+ public function testIsOidcAccountFalseForNonXoauth2(): void {
+ $this->providerMapper->expects($this->never())->method('findByEmailDomain');
+
+ $this->assertFalse($this->integration->isOidcAccount($this->account('alice@example.com', 'password')));
+ }
+
+ public function testIsOidcAccountTrueWhenProviderMatches(): void {
+ $this->providerMapper->method('findByEmailDomain')->willReturn($this->provider());
+
+ $this->assertTrue($this->integration->isOidcAccount($this->account('alice@example.com')));
+ }
+
+ public function testIsOidcAccountFalseWhenNoProviderMatches(): void {
+ $this->providerMapper->method('findByEmailDomain')->willReturn(null);
+
+ $this->assertFalse($this->integration->isOidcAccount($this->account('alice@example.com')));
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcConnectTest.php b/tests/Unit/Integration/OidcIntegration/OidcConnectTest.php
new file mode 100644
index 0000000000..437cdf5e7f
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcConnectTest.php
@@ -0,0 +1,158 @@
+provider();
+ $account = $this->account('alice@example.com');
+
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->crypto->method('decrypt')->with('encrypted-secret')->willReturn('plain-secret');
+ $this->crypto->method('encrypt')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'access_token' => 'the-access-token',
+ 'refresh_token' => 'the-refresh-token',
+ 'expires_in' => 3600,
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('post')
+ ->with('https://idp.example.com/token', $this->callback(function (array $options): bool {
+ $body = $options['body'];
+ return $body['grant_type'] === 'authorization_code'
+ && $body['code'] === 'auth-code'
+ && $body['client_id'] === 'mail-client'
+ && $body['client_secret'] === 'plain-secret';
+ }))
+ ->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->finishConnect($provider, $account, 'auth-code');
+
+ $this->assertSame('enc:the-access-token', $result->getMailAccount()->getOauthAccessToken());
+ $this->assertSame('enc:the-refresh-token', $result->getMailAccount()->getOauthRefreshToken());
+ $this->assertSame(4600, $result->getMailAccount()->getOauthTokenTtl());
+ }
+
+ public function testFinishConnectSendsPkceVerifier(): void {
+ $provider = $this->provider();
+ $provider->setClientSecret(null);
+ $account = $this->account('alice@example.com');
+
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->crypto->method('encrypt')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'access_token' => 'the-access-token',
+ 'expires_in' => 3600,
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('post')
+ ->with('https://idp.example.com/token', $this->callback(function (array $options): bool {
+ $body = $options['body'];
+ return $body['code_verifier'] === 'verifier-123'
+ && !isset($body['client_secret']);
+ }))
+ ->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->integration->finishConnect($provider, $account, 'auth-code', 'verifier-123');
+ }
+
+ public function testFinishConnectUsesManualTokenEndpoint(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setTokenEndpoint('https://manual.example.com/token');
+ $account = $this->account('alice@example.com');
+
+ $this->crypto->method('decrypt')->willReturn('plain-secret');
+ $this->crypto->method('encrypt')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'access_token' => 'tok',
+ 'expires_in' => 3600,
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('post')
+ ->with('https://manual.example.com/token', $this->anything())
+ ->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->integration->finishConnect($provider, $account, 'auth-code');
+ }
+
+ public function testFinishConnectReturnsAccountOnDiscoveryFailure(): void {
+ $provider = $this->provider();
+ $account = $this->account('alice@example.com');
+ $this->cache->method('get')->willReturn(null);
+ $client = $this->createMock(IClient::class);
+ $client->method('get')->willThrowException(new \Exception('discovery down'));
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->finishConnect($provider, $account, 'code');
+
+ $this->assertNull($result->getMailAccount()->getOauthAccessToken());
+ }
+
+ public function testFinishConnectReturnsAccountOnHttpFailure(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setTokenEndpoint('https://idp.example.com/token');
+ $account = $this->account('alice@example.com');
+ $this->crypto->method('decrypt')->willReturn('plain-secret');
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willThrowException(new \Exception('boom'));
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->finishConnect($provider, $account, 'code');
+
+ $this->assertNull($result->getMailAccount()->getOauthAccessToken());
+ }
+
+ public function testFinishConnectIgnoresUnexpectedResponse(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setTokenEndpoint('https://idp.example.com/token');
+ $account = $this->account('alice@example.com');
+ $this->crypto->method('decrypt')->willReturn('plain-secret');
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode(['error' => 'invalid_grant']));
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->finishConnect($provider, $account, 'code');
+
+ $this->assertNull($result->getMailAccount()->getOauthAccessToken());
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcEndpointsTest.php b/tests/Unit/Integration/OidcIntegration/OidcEndpointsTest.php
new file mode 100644
index 0000000000..32103037e0
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcEndpointsTest.php
@@ -0,0 +1,163 @@
+provider();
+ $this->cache->method('get')
+ ->with($provider->getDiscoveryUrl())
+ ->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $discovery = $this->integration->getDiscovery($provider);
+
+ $this->assertSame('https://idp.example.com/token', $discovery['token_endpoint']);
+ }
+
+ public function testGetDiscoveryFetchesAndCaches(): void {
+ $provider = $this->provider();
+ $this->cache->method('get')->willReturn(null);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('get')
+ ->with($provider->getDiscoveryUrl())
+ ->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->cache->expects($this->once())->method('set');
+
+ $discovery = $this->integration->getDiscovery($provider);
+
+ $this->assertSame('https://idp.example.com/auth', $discovery['authorization_endpoint']);
+ }
+
+ public function testGetDiscoveryThrowsOnMissingEndpoints(): void {
+ $provider = $this->provider();
+ $this->cache->method('get')->willReturn(null);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode(['issuer' => 'https://idp.example.com']));
+ $client = $this->createMock(IClient::class);
+ $client->method('get')->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->expectException(\Exception::class);
+
+ $this->integration->getDiscovery($provider);
+ }
+
+ public function testGetEndpointsFromDiscovery(): void {
+ $provider = $this->provider();
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+
+ $endpoints = $this->integration->getEndpoints($provider);
+
+ $this->assertSame('https://idp.example.com/auth', $endpoints['authorization_endpoint']);
+ $this->assertSame('https://idp.example.com/token', $endpoints['token_endpoint']);
+ }
+
+ public function testGetEndpointsManualSkipsDiscovery(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setAuthorizationEndpoint('https://manual.example.com/authorize');
+ $provider->setTokenEndpoint('https://manual.example.com/token');
+ $this->cache->expects($this->never())->method('get');
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $endpoints = $this->integration->getEndpoints($provider);
+
+ $this->assertSame('https://manual.example.com/authorize', $endpoints['authorization_endpoint']);
+ $this->assertSame('https://manual.example.com/token', $endpoints['token_endpoint']);
+ }
+
+ public function testGetAuthorizationUrlBuildsUrl(): void {
+ $provider = $this->provider();
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->urlGenerator->method('linkToRouteAbsolute')
+ ->with('mail.oidcIntegration.oauthRedirect')
+ ->willReturn('https://cloud.example.com/oidc-auth');
+
+ $url = $this->integration->getAuthorizationUrl($provider, 'the-state');
+
+ $this->assertStringStartsWith('https://idp.example.com/auth?', $url);
+ parse_str(parse_url($url, PHP_URL_QUERY), $query);
+ $this->assertSame('mail-client', $query['client_id']);
+ $this->assertSame('code', $query['response_type']);
+ $this->assertSame('the-state', $query['state']);
+ $this->assertSame('https://cloud.example.com/oidc-auth', $query['redirect_uri']);
+ $this->assertSame('openid email offline_access', $query['scope']);
+ }
+
+ public function testGetAuthorizationUrlAppendsToExistingQuery(): void {
+ $provider = $this->provider();
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth?foo=bar',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+
+ $url = $this->integration->getAuthorizationUrl($provider, 'the-state');
+
+ $this->assertStringStartsWith('https://idp.example.com/auth?foo=bar&', $url);
+ }
+
+ public function testIntrospectionUnavailableWithManualEndpointsAndNoIntrospectionUrl(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setTokenEndpoint('https://idp.example.com/token');
+ $provider->setIntrospectionEndpoint('');
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $this->assertNull($this->integration->introspectToken($provider, 'tok', 'refresh_token'));
+ }
+
+ public function testIntrospectionUsesManualIntrospectionEndpoint(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setTokenEndpoint('https://idp.example.com/token');
+ $provider->setIntrospectionEndpoint('https://manual.example.com/introspect');
+ $this->crypto->method('decrypt')->willReturn('plain-secret');
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode(['active' => false]));
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('post')
+ ->with('https://manual.example.com/introspect', $this->callback(function (array $options): bool {
+ return $options['body']['token'] === 'tok'
+ && $options['body']['token_type_hint'] === 'refresh_token';
+ }))
+ ->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->assertFalse($this->integration->introspectToken($provider, 'tok', 'refresh_token'));
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcIntegrationTestCase.php b/tests/Unit/Integration/OidcIntegration/OidcIntegrationTestCase.php
new file mode 100644
index 0000000000..9a716381ea
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcIntegrationTestCase.php
@@ -0,0 +1,128 @@
+timeFactory = $this->createMock(ITimeFactory::class);
+ $this->crypto = $this->createMock(ICrypto::class);
+ $this->clientService = $this->createMock(IClientService::class);
+ $this->urlGenerator = $this->createMock(IURLGenerator::class);
+ $this->providerMapper = $this->createMock(OidcProviderMapper::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->cache = $this->createMock(ICache::class);
+
+ $cacheFactory = $this->createMock(ICacheFactory::class);
+ $cacheFactory->method('createDistributed')->willReturn($this->cache);
+
+ $this->integration = new OidcIntegration(
+ $this->timeFactory,
+ $this->crypto,
+ $this->clientService,
+ $this->urlGenerator,
+ $this->providerMapper,
+ $this->logger,
+ $cacheFactory,
+ );
+ }
+
+ protected function account(string $email, string $authMethod = 'xoauth2'): Account {
+ $mailAccount = new MailAccount();
+ $mailAccount->setEmail($email);
+ $mailAccount->setAuthMethod($authMethod);
+ return new Account($mailAccount);
+ }
+
+ protected function provider(): OidcProvider {
+ $provider = new OidcProvider();
+ $provider->setId(1);
+ $provider->setName('Test');
+ $provider->setEmailDomain('example.com');
+ $provider->setClientId('mail-client');
+ $provider->setClientSecret('encrypted-secret');
+ $provider->setDiscoveryUrl('https://idp.example.com/.well-known/openid-configuration');
+ $provider->setScope('openid email offline_access');
+ return $provider;
+ }
+
+ /**
+ * Account whose access token has just expired, with the given refresh token.
+ */
+ protected function expiredAccount(?string $refreshToken): Account {
+ $account = $this->account('alice@example.com');
+ if ($refreshToken !== null) {
+ $account->getMailAccount()->setOauthRefreshToken($refreshToken);
+ }
+ $account->getMailAccount()->setOauthTokenTtl(1000);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+ return $account;
+ }
+
+ protected function discoveryWithIntrospection(): string {
+ return json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ 'introspection_endpoint' => 'https://idp.example.com/introspect',
+ ]);
+ }
+
+ /**
+ * Token endpoint fails; introspection answers with the given payload.
+ */
+ protected function failingRefreshClient(?array $introspection): IClient {
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willReturnCallback(
+ function (string $url) use ($introspection): IResponse {
+ if (str_contains($url, '/introspect')) {
+ if ($introspection === null) {
+ throw new \Exception('introspection down');
+ }
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode($introspection));
+ return $response;
+ }
+ throw new \Exception('refresh rejected');
+ },
+ );
+ return $client;
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcProviderCrudTest.php b/tests/Unit/Integration/OidcIntegration/OidcProviderCrudTest.php
new file mode 100644
index 0000000000..ea9ccca09c
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcProviderCrudTest.php
@@ -0,0 +1,97 @@
+providerMapper->expects($this->once())
+ ->method('getAll')
+ ->willReturn([$this->provider()]);
+
+ $this->assertCount(1, $this->integration->getProviders());
+ }
+
+ public function testGetProviderDelegatesToMapper(): void {
+ $provider = $this->provider();
+ $this->providerMapper->expects($this->once())
+ ->method('get')
+ ->with(5)
+ ->willReturn($provider);
+
+ $this->assertSame($provider, $this->integration->getProvider(5));
+ }
+
+ public function testCreateProviderEncryptsSecretAndInserts(): void {
+ $provider = $this->provider();
+ $provider->setClientSecret('plain-secret');
+ $this->providerMapper->method('validate')->willReturn($provider);
+ $this->crypto->expects($this->once())
+ ->method('encrypt')
+ ->with('plain-secret')
+ ->willReturn('enc:plain-secret');
+ $this->providerMapper->expects($this->once())
+ ->method('insert')
+ ->willReturnArgument(0);
+
+ $result = $this->integration->createProvider(['id' => 99, 'name' => 'x']);
+
+ $this->assertSame('enc:plain-secret', $result->getClientSecret());
+ }
+
+ public function testCreateProviderWithoutSecretDoesNotEncrypt(): void {
+ $provider = new OidcProvider();
+ $this->providerMapper->method('validate')->willReturn($provider);
+ $this->crypto->expects($this->never())->method('encrypt');
+ $this->providerMapper->expects($this->once())->method('insert')->willReturnArgument(0);
+
+ $this->integration->createProvider([]);
+ }
+
+ public function testUpdateProviderRequiresId(): void {
+ $this->providerMapper->method('validate')->willReturn(new OidcProvider());
+
+ $this->expectException(OidcProviderNotFoundException::class);
+
+ $this->integration->updateProvider(['name' => 'x']);
+ }
+
+ public function testUpdateProviderEncryptsAndUpdates(): void {
+ $provider = $this->provider();
+ $provider->setClientSecret('plain-secret');
+ $this->providerMapper->method('validate')->willReturn($provider);
+ $this->crypto->method('encrypt')->willReturn('enc:plain-secret');
+ $this->providerMapper->expects($this->once())->method('update')->willReturnArgument(0);
+
+ $result = $this->integration->updateProvider(['id' => 1]);
+
+ $this->assertSame('enc:plain-secret', $result->getClientSecret());
+ }
+
+ public function testDeleteProviderDeletesWhenFound(): void {
+ $provider = $this->provider();
+ $this->providerMapper->method('get')->with(1)->willReturn($provider);
+ $this->providerMapper->expects($this->once())->method('delete')->with($provider);
+
+ $this->integration->deleteProvider(1);
+ }
+
+ public function testDeleteProviderNoopWhenMissing(): void {
+ $this->providerMapper->method('get')->willReturn(null);
+ $this->providerMapper->expects($this->never())->method('delete');
+
+ $this->integration->deleteProvider(99);
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcReauthTest.php b/tests/Unit/Integration/OidcIntegration/OidcReauthTest.php
new file mode 100644
index 0000000000..cdaa88ff46
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcReauthTest.php
@@ -0,0 +1,86 @@
+expiredAccount(null);
+ $this->providerMapper->method('findByEmailDomain')->willReturn($this->provider());
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertTrue($result->getMailAccount()->getOauthNeedsReauth());
+ }
+
+ public function testRefreshFlagsReauthWhenIntrospectionSaysInactive(): void {
+ $account = $this->expiredAccount('enc-refresh');
+ $this->providerMapper->method('findByEmailDomain')->willReturn($this->provider());
+ $this->cache->method('get')->willReturn($this->discoveryWithIntrospection());
+ $this->crypto->method('decrypt')->willReturn('plain');
+ $this->clientService->method('newClient')->willReturn($this->failingRefreshClient(['active' => false]));
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertTrue($result->getMailAccount()->getOauthNeedsReauth());
+ }
+
+ public function testRefreshDoesNotFlagWhenTokenStillActive(): void {
+ $account = $this->expiredAccount('enc-refresh');
+ $this->providerMapper->method('findByEmailDomain')->willReturn($this->provider());
+ $this->cache->method('get')->willReturn($this->discoveryWithIntrospection());
+ $this->crypto->method('decrypt')->willReturn('plain');
+ $this->clientService->method('newClient')->willReturn($this->failingRefreshClient(['active' => true]));
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertNotTrue($result->getMailAccount()->getOauthNeedsReauth());
+ }
+
+ public function testRefreshDoesNotFlagWhenIntrospectionUnavailable(): void {
+ $account = $this->expiredAccount('enc-refresh');
+ $this->providerMapper->method('findByEmailDomain')->willReturn($this->provider());
+ $this->cache->method('get')->willReturn($this->discoveryWithIntrospection());
+ $this->crypto->method('decrypt')->willReturn('plain');
+ $this->clientService->method('newClient')->willReturn($this->failingRefreshClient(null));
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertNotTrue($result->getMailAccount()->getOauthNeedsReauth());
+ }
+
+ public function testRefreshClearsReauthFlagOnSuccess(): void {
+ $account = $this->expiredAccount('enc-refresh');
+ $account->getMailAccount()->setOauthNeedsReauth(true);
+ $this->providerMapper->method('findByEmailDomain')->willReturn($this->provider());
+ $this->cache->method('get')->willReturn($this->discoveryWithIntrospection());
+ $this->crypto->method('decrypt')->willReturn('plain');
+ $this->crypto->method('encrypt')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'access_token' => 'fresh',
+ 'expires_in' => 3600,
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertFalse($result->getMailAccount()->getOauthNeedsReauth());
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegration/OidcRefreshTest.php b/tests/Unit/Integration/OidcIntegration/OidcRefreshTest.php
new file mode 100644
index 0000000000..5b0083113e
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcRefreshTest.php
@@ -0,0 +1,191 @@
+account('alice@example.com');
+
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $this->integration->refresh($account);
+ }
+
+ public function testRefreshNoopWhenTokenStillFresh(): void {
+ $account = $this->account('alice@example.com');
+ $account->getMailAccount()->setOauthRefreshToken('enc-refresh');
+ $account->getMailAccount()->setOauthTokenTtl(10000);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $this->integration->refresh($account);
+ }
+
+ public function testRefreshNoopWhenProviderGone(): void {
+ $account = $this->account('alice@example.com');
+ $account->getMailAccount()->setOauthRefreshToken('enc-refresh');
+ $account->getMailAccount()->setOauthTokenTtl(1000);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+ $this->providerMapper->method('findByEmailDomain')->willReturn(null);
+
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $this->integration->refresh($account);
+ }
+
+ public function testRefreshUpdatesAccessToken(): void {
+ $provider = $this->provider();
+ $account = $this->account('alice@example.com');
+ $account->getMailAccount()->setOauthRefreshToken('enc-refresh');
+ $account->getMailAccount()->setOauthTokenTtl(1000);
+
+ $this->timeFactory->method('getTime')->willReturn(1000);
+ $this->providerMapper->method('findByEmailDomain')->willReturn($provider);
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->crypto->method('decrypt')->willReturnCallback(static function (string $v): string {
+ return match ($v) {
+ 'enc-refresh' => 'plain-refresh',
+ 'encrypted-secret' => 'plain-secret',
+ default => $v,
+ };
+ });
+ $this->crypto->method('encrypt')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'access_token' => 'fresh-access-token',
+ 'expires_in' => 3600,
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->expects($this->once())
+ ->method('post')
+ ->with('https://idp.example.com/token', $this->callback(function (array $options): bool {
+ $body = $options['body'];
+ return $body['grant_type'] === 'refresh_token'
+ && $body['refresh_token'] === 'plain-refresh';
+ }))
+ ->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertSame('enc:fresh-access-token', $result->getMailAccount()->getOauthAccessToken());
+ $this->assertSame(4600, $result->getMailAccount()->getOauthTokenTtl());
+ }
+
+ public function testRefreshRotatesRefreshToken(): void {
+ $provider = $this->provider();
+ $account = $this->account('alice@example.com');
+ $account->getMailAccount()->setOauthRefreshToken('enc-refresh');
+ $account->getMailAccount()->setOauthTokenTtl(1000);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+ $this->providerMapper->method('findByEmailDomain')->willReturn($provider);
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->crypto->method('decrypt')->willReturn('plain');
+ $this->crypto->method('encrypt')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode([
+ 'access_token' => 'new-access',
+ 'refresh_token' => 'rotated-refresh',
+ 'expires_in' => 3600,
+ ]));
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertSame('enc:rotated-refresh', $result->getMailAccount()->getOauthRefreshToken());
+ }
+
+ public function testRefreshReturnsAccountOnEndpointFailure(): void {
+ $provider = $this->provider();
+ $account = $this->account('alice@example.com');
+ $account->getMailAccount()->setOauthRefreshToken('enc-refresh');
+ $account->getMailAccount()->setOauthTokenTtl(1000);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+ $this->providerMapper->method('findByEmailDomain')->willReturn($provider);
+ $this->cache->method('get')->willReturn(null);
+ $client = $this->createMock(IClient::class);
+ $client->method('get')->willThrowException(new \Exception('discovery down'));
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->integration->refresh($account);
+
+ $this->assertSame('enc-refresh', $account->getMailAccount()->getOauthRefreshToken());
+ }
+
+ public function testRefreshReturnsAccountOnHttpFailure(): void {
+ $provider = $this->provider();
+ $provider->setManualEndpoints(true);
+ $provider->setTokenEndpoint('https://idp.example.com/token');
+ $account = $this->account('alice@example.com');
+ $account->getMailAccount()->setOauthRefreshToken('enc-refresh');
+ $account->getMailAccount()->setOauthTokenTtl(1000);
+ $this->timeFactory->method('getTime')->willReturn(1000);
+ $this->providerMapper->method('findByEmailDomain')->willReturn($provider);
+ $this->crypto->method('decrypt')->willReturn('plain-refresh');
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willThrowException(new \Exception('boom'));
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $this->integration->refresh($account);
+
+ $this->assertSame('enc-refresh', $account->getMailAccount()->getOauthRefreshToken());
+ }
+
+ public function testRefreshFlagsReauthWhenRefreshTokenCannotBeDecrypted(): void {
+ $provider = $this->provider();
+ $account = $this->expiredAccount('enc-refresh');
+ $this->providerMapper->method('findByEmailDomain')->willReturn($provider);
+ $this->crypto->method('decrypt')->willThrowException(new \Exception('bad ciphertext'));
+ $this->clientService->expects($this->never())->method('newClient');
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertTrue($result->getMailAccount()->getOauthNeedsReauth());
+ }
+
+ public function testRefreshIsNoopOnUnexpectedTokenResponse(): void {
+ $provider = $this->provider();
+ $account = $this->expiredAccount('enc-refresh');
+ $this->providerMapper->method('findByEmailDomain')->willReturn($provider);
+ $this->cache->method('get')->willReturn(json_encode([
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ ]));
+ $this->crypto->method('decrypt')->willReturn('plain');
+
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')->willReturn(json_encode(['error' => 'invalid_grant']));
+ $client = $this->createMock(IClient::class);
+ $client->method('post')->willReturn($response);
+ $this->clientService->method('newClient')->willReturn($client);
+
+ $result = $this->integration->refresh($account);
+
+ $this->assertNull($result->getMailAccount()->getOauthAccessToken());
+ $this->assertNotTrue($result->getMailAccount()->getOauthNeedsReauth());
+ }
+}
diff --git a/tests/Unit/Listener/OauthTokenRefreshListenerTest.php b/tests/Unit/Listener/OauthTokenRefreshListenerTest.php
new file mode 100644
index 0000000000..a1959c1f60
--- /dev/null
+++ b/tests/Unit/Listener/OauthTokenRefreshListenerTest.php
@@ -0,0 +1,92 @@
+googleIntegration = $this->createMock(GoogleIntegration::class);
+ $this->microsoftIntegration = $this->createMock(MicrosoftIntegration::class);
+ $this->oidcIntegration = $this->createMock(OidcIntegration::class);
+ $this->accountService = $this->createMock(AccountService::class);
+
+ $this->listener = new OauthTokenRefreshListener(
+ $this->googleIntegration,
+ $this->microsoftIntegration,
+ $this->oidcIntegration,
+ $this->accountService,
+ );
+ }
+
+ private function account(): Account {
+ return new Account(new MailAccount());
+ }
+
+ public function testIgnoresUnrelatedEvent(): void {
+ $this->accountService->expects($this->never())->method('update');
+
+ $this->listener->handle(new Event());
+ }
+
+ public function testRefreshesOidcAccount(): void {
+ $account = $this->account();
+ $this->googleIntegration->method('isGoogleOauthAccount')->willReturn(false);
+ $this->microsoftIntegration->method('isMicrosoftOauthAccount')->willReturn(false);
+ $this->oidcIntegration->method('isOidcAccount')->with($account)->willReturn(true);
+ $this->oidcIntegration->expects($this->once())
+ ->method('refresh')
+ ->with($account)
+ ->willReturn($account);
+ $this->accountService->expects($this->once())
+ ->method('update')
+ ->with($account->getMailAccount());
+
+ $this->listener->handle(new BeforeImapClientCreated($account));
+ }
+
+ public function testOidcNotCheckedWhenGoogleMatches(): void {
+ $account = $this->account();
+ $this->googleIntegration->method('isGoogleOauthAccount')->willReturn(true);
+ $this->googleIntegration->method('refresh')->willReturn($account);
+ $this->oidcIntegration->expects($this->never())->method('isOidcAccount');
+ $this->oidcIntegration->expects($this->never())->method('refresh');
+
+ $this->listener->handle(new BeforeImapClientCreated($account));
+ }
+
+ public function testDoesNotUpdateWhenNoIntegrationMatches(): void {
+ $account = $this->account();
+ $this->googleIntegration->method('isGoogleOauthAccount')->willReturn(false);
+ $this->microsoftIntegration->method('isMicrosoftOauthAccount')->willReturn(false);
+ $this->oidcIntegration->method('isOidcAccount')->willReturn(false);
+ $this->accountService->expects($this->never())->method('update');
+
+ $this->listener->handle(new BeforeImapClientCreated($account));
+ }
+}
diff --git a/tests/Unit/Settings/AdminSettingsTest.php b/tests/Unit/Settings/AdminSettingsTest.php
index cc95185ebb..0a07a96ac6 100644
--- a/tests/Unit/Settings/AdminSettingsTest.php
+++ b/tests/Unit/Settings/AdminSettingsTest.php
@@ -37,7 +37,7 @@ public function testGetSection() {
}
public function testGetForm() {
- $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(14))
+ $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(16))
->method('provideInitialState')
->withConsecutive(
[
@@ -80,6 +80,14 @@ public function testGetForm() {
'importance_classification_default',
$this->anything()
],
+ [
+ 'oidc_providers',
+ $this->anything()
+ ],
+ [
+ 'oidc_redirect_url',
+ $this->anything()
+ ],
[
'microsoft_oauth_tenant_id',
$this->anything()