From 6d3c2e9663a4081d55363870dc87fe84aa1bedae Mon Sep 17 00:00:00 2001
From: Leah
Date: Sun, 19 Jul 2026 00:02:45 +0200
Subject: [PATCH 1/7] feat: generic OIDC/XOAUTH2 support for individual mail
accounts
Let an administrator register OpenID Connect providers so users whose email
domain matches can authenticate their IMAP/SMTP connection over XOAUTH2 via an
interactive authorization-code flow, instead of storing a password. This
generalises the existing Google and Microsoft integrations to any OIDC
provider (Keycloak, Authentik, Stalwart, ...).
- New `mail_oidc_providers` table with `OidcProvider` entity and mapper
(matched to accounts by the user's email domain; separate IMAP/SMTP
host/port/SSL, client id/secret, discovery URL or manual endpoints, scopes).
- `OidcIntegration` service: discovery fetch and cache (or admin-defined
endpoints), authorization-code exchange, cron-safe token refresh, and
provider CRUD with the client secret encrypted at rest.
- `OidcIntegrationController` and routes: admin CRUD, the authorize redirect
that resolves discovery server-side, and the OAuth callback, reusing
`OauthStateService`.
- `OauthTokenRefreshListener`: refresh the OIDC token before IMAP connect,
mirroring the Google/Microsoft branches.
- Admin UI (`OidcAdminSettings` / `OidcProviderForm`) to manage 0..n providers
and `AccountForm` email-domain detection that pre-fills the mail servers and
opens the SSO consent popup.
- Documentation and unit tests.
Two deliberate divergences from the reviewer's earlier sketch, to be flagged on
the PR: multiple providers (0..n) rather than a single global config, and
matching by email domain rather than IMAP hostname.
Closes #12491
Assisted-by: Claude:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8
Signed-off-by: Leah
---
appinfo/routes.php | 30 +
doc/oidc-xoauth2.md | 521 ++++++++++++++++
lib/Controller/OidcIntegrationController.php | 159 +++++
lib/Controller/PageController.php | 21 +
lib/Db/OidcProvider.php | 102 ++++
lib/Db/OidcProviderMapper.php | 136 +++++
lib/Integration/OidcIntegration.php | 358 +++++++++++
lib/Listener/OauthTokenRefreshListener.php | 4 +
.../Version5300Date20260717120000.php | 118 ++++
lib/Settings/AdminSettings.php | 10 +
src/components/AccountForm.vue | 121 +++-
src/components/settings/AdminSettings.vue | 8 +
src/components/settings/OidcAdminSettings.vue | 129 ++++
src/components/settings/OidcProviderForm.vue | 326 ++++++++++
src/service/OidcIntegrationService.js | 31 +
.../OidcIntegrationControllerTest.php | 237 ++++++++
tests/Unit/Controller/PageControllerTest.php | 22 +-
tests/Unit/Db/OidcProviderMapperTest.php | 193 ++++++
tests/Unit/Db/OidcProviderTest.php | 76 +++
.../Unit/Integration/OidcIntegrationTest.php | 573 ++++++++++++++++++
.../OauthTokenRefreshListenerTest.php | 92 +++
tests/Unit/Settings/AdminSettingsTest.php | 10 +-
22 files changed, 3247 insertions(+), 30 deletions(-)
create mode 100644 doc/oidc-xoauth2.md
create mode 100644 lib/Controller/OidcIntegrationController.php
create mode 100644 lib/Db/OidcProvider.php
create mode 100644 lib/Db/OidcProviderMapper.php
create mode 100644 lib/Integration/OidcIntegration.php
create mode 100644 lib/Migration/Version5300Date20260717120000.php
create mode 100644 src/components/settings/OidcAdminSettings.vue
create mode 100644 src/components/settings/OidcProviderForm.vue
create mode 100644 src/service/OidcIntegrationService.js
create mode 100644 tests/Unit/Controller/OidcIntegrationControllerTest.php
create mode 100644 tests/Unit/Db/OidcProviderMapperTest.php
create mode 100644 tests/Unit/Db/OidcProviderTest.php
create mode 100644 tests/Unit/Integration/OidcIntegrationTest.php
create mode 100644 tests/Unit/Listener/OauthTokenRefreshListenerTest.php
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 0e116e1349..6ed7f115a1 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -465,6 +465,36 @@
'url' => '/integration/google-auth',
'verb' => 'GET',
],
+ [
+ 'name' => 'oidcIntegration#index',
+ 'url' => '/api/integration/oidc/providers',
+ 'verb' => 'GET',
+ ],
+ [
+ 'name' => 'oidcIntegration#create',
+ 'url' => '/api/integration/oidc/providers',
+ 'verb' => 'POST',
+ ],
+ [
+ 'name' => 'oidcIntegration#update',
+ 'url' => '/api/integration/oidc/providers/{id}',
+ 'verb' => 'POST',
+ ],
+ [
+ 'name' => 'oidcIntegration#destroy',
+ 'url' => '/api/integration/oidc/providers/{id}',
+ 'verb' => 'DELETE',
+ ],
+ [
+ 'name' => 'oidcIntegration#authorize',
+ 'url' => '/integration/oidc-auth/start',
+ 'verb' => 'GET',
+ ],
+ [
+ 'name' => 'oidcIntegration#oauthRedirect',
+ 'url' => '/integration/oidc-auth',
+ 'verb' => 'GET',
+ ],
[
'name' => 'microsoftIntegration#configure',
'url' => '/api/integration/microsoft',
diff --git a/doc/oidc-xoauth2.md b/doc/oidc-xoauth2.md
new file mode 100644
index 0000000000..7e08e85ccc
--- /dev/null
+++ b/doc/oidc-xoauth2.md
@@ -0,0 +1,521 @@
+
+
+# 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.
+
+## 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..023bc98d53
--- /dev/null
+++ b/lib/Controller/OidcIntegrationController.php
@@ -0,0 +1,159 @@
+oidcIntegration->getProviders());
+ }
+
+ public function create(array $data): JSONResponse {
+ try {
+ return new JSONResponse($this->oidcIntegration->createProvider($data));
+ } catch (ValidationException $e) {
+ return HttpJsonResponse::fail([$e->getFields()]);
+ } catch (\Exception $e) {
+ return HttpJsonResponse::fail([$e->getMessage()]);
+ }
+ }
+
+ public function update(int $id, array $data): JSONResponse {
+ try {
+ return new JSONResponse(
+ $this->oidcIntegration->updateProvider(array_merge($data, ['id' => $id])),
+ );
+ } catch (ValidationException $e) {
+ return HttpJsonResponse::fail([$e->getFields()]);
+ } catch (\Exception $e) {
+ return HttpJsonResponse::fail([$e->getMessage()]);
+ }
+ }
+
+ public function destroy(int $id): JSONResponse {
+ $this->oidcIntegration->deleteProvider($id);
+ return new JSONResponse([]);
+ }
+
+ /**
+ * Start the interactive consent flow: resolve the provider's discovery document
+ * and redirect the popup to the IdP authorization endpoint. Opened as a top-level
+ * navigation, so it carries no CSRF token.
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function authorize(int $providerId, string $state): Response {
+ $provider = $this->oidcIntegration->getProvider($providerId);
+ if ($provider === null) {
+ $this->logger->warning('Cannot start OIDC consent flow: unknown provider {providerId}', [
+ 'providerId' => $providerId,
+ ]);
+ return $this->done();
+ }
+
+ try {
+ $url = $this->oidcIntegration->getAuthorizationUrl($provider, $state);
+ } catch (\Exception $e) {
+ $this->logger->error('Cannot start OIDC consent flow: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'providerId' => $providerId,
+ ]);
+ return $this->done();
+ }
+
+ return new RedirectResponse($url);
+ }
+
+ /**
+ * OAuth authorization-code callback. Exchanges the code for tokens and stores
+ * them on the account matched by the CSRF state.
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function oauthRedirect(?string $code, ?string $state, ?string $error): Response {
+ if ($this->userId === null || !isset($code, $state)) {
+ return $this->done();
+ }
+
+ try {
+ $accountId = $this->oauthStateService->validateAndConsume($state, $this->userId);
+ $account = $this->accountService->find($this->userId, $accountId);
+ } catch (InvalidOauthStateException|ClientException $e) {
+ $this->logger->warning('Cannot link OIDC account: invalid OAuth state', [
+ 'exception' => $e,
+ ]);
+ return $this->done();
+ }
+
+ $provider = $this->oidcIntegration->getProviderForAccount($account);
+ if ($provider === null) {
+ $this->logger->warning('Cannot link OIDC account {accountId}: no provider matches its email domain', [
+ 'accountId' => $account->getId(),
+ ]);
+ return $this->done();
+ }
+
+ $updated = $this->oidcIntegration->finishConnect($provider, $account, $code);
+ $this->accountService->update($updated->getMailAccount());
+ try {
+ $this->mailboxSync->sync($account, $this->logger);
+ } catch (ServiceException $e) {
+ $this->logger->error('Failed syncing the newly linked OIDC account: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ }
+ return $this->done();
+ }
+
+ private function done(): StandaloneTemplateResponse {
+ return new StandaloneTemplateResponse(
+ Application::APP_ID,
+ 'oauth_done',
+ [],
+ 'guest',
+ );
+ }
+}
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/OidcProvider.php b/lib/Db/OidcProvider.php
new file mode 100644
index 0000000000..a53cc8de48
--- /dev/null
+++ b/lib/Db/OidcProvider.php
@@ -0,0 +1,102 @@
+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(),
+ 'scope' => $this->getScope(),
+ ];
+ }
+}
diff --git a/lib/Db/OidcProviderMapper.php b/lib/Db/OidcProviderMapper.php
new file mode 100644
index 0000000000..29c56eaeb9
--- /dev/null
+++ b/lib/Db/OidcProviderMapper.php
@@ -0,0 +1,136 @@
+
+ */
+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']);
+ $provider->setEmailDomain($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'] : '');
+ $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/Integration/OidcIntegration.php b/lib/Integration/OidcIntegration.php
new file mode 100644
index 0000000000..99a11e1385
--- /dev/null
+++ b/lib/Integration/OidcIntegration.php
@@ -0,0 +1,358 @@
+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.
+ *
+ * @throws \OCA\Mail\Exception\ValidationException
+ */
+ public function updateProvider(array $data): OidcProvider {
+ $provider = $this->providerMapper->validate($data);
+ if ($provider->getId() === null) {
+ throw new \InvalidArgumentException('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 Exception('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.
+ *
+ * @return array{authorization_endpoint: string, token_endpoint: string}
+ * @throws Exception when discovery is needed but can not be resolved
+ */
+ public function getEndpoints(OidcProvider $provider): array {
+ if ($provider->getManualEndpoints()) {
+ return [
+ 'authorization_endpoint' => $provider->getAuthorizationEndpoint(),
+ 'token_endpoint' => $provider->getTokenEndpoint(),
+ ];
+ }
+
+ $discovery = $this->getDiscovery($provider);
+ return [
+ 'authorization_endpoint' => (string)$discovery['authorization_endpoint'],
+ 'token_endpoint' => (string)$discovery['token_endpoint'],
+ ];
+ }
+
+ /**
+ * 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,
+ ]);
+ } catch (Exception $e) {
+ $this->logger->error('Could not link OIDC account: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return $account;
+ }
+
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ 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']);
+ 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 || $oauthRefreshToken === 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;
+ }
+
+ 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' => $this->crypto->decrypt($oauthRefreshToken),
+ ];
+ $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,
+ ]);
+ } catch (Exception $e) {
+ $this->logger->warning('Could not refresh OIDC token for account {accountId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ $account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($data['access_token']));
+ $account->getMailAccount()->setOauthTokenTtl($this->timeFactory->getTime() + $data['expires_in']);
+ // 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/Version5300Date20260717120000.php b/lib/Migration/Version5300Date20260717120000.php
new file mode 100644
index 0000000000..9dfa6a51de
--- /dev/null
+++ b/lib/Migration/Version5300Date20260717120000.php
@@ -0,0 +1,118 @@
+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('scope', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ 'default' => 'openid email offline_access',
+ ]);
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['email_domain'], 'mail_oidc_dm_idx');
+ }
+
+ 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..b83b2fe26a 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.') }}
+
(p.emailDomain || '').toLowerCase() === domain) ?? null
+ },
+
+ isOidcAccount() {
+ return !this.isGoogleAccount && !this.isMicrosoftAccount && !!this.oidcProvider
+ },
+
hasPasswordAlternatives() {
return !!this.googleOauthUrl
|| !!this.microsoftOauthUrl
@@ -424,12 +454,19 @@ export default {
useOauth() {
return (this.isGoogleAccount && this.googleOauthUrl)
|| (this.isMicrosoftAccount && this.microsoftOauthUrl)
+ || this.isOidcAccount
},
submitButtonText() {
if (this.loading) {
return this.loadingMessage ?? t('mail', 'Connecting')
}
+ if (this.isOidcAccount) {
+ const provider = this.oidcProvider.name || this.oidcProvider.emailDomain
+ return this.account
+ ? t('mail', 'Reconnect to {provider}', { provider })
+ : t('mail', 'Connect to {provider}', { provider })
+ }
if (this.mode === 'manual' && this.useOauth) {
if (this.isGoogleAccount) {
return this.account ? t('mail', 'Reconnect Google account') : t('mail', 'Sign in with Google')
@@ -441,7 +478,57 @@ export default {
},
},
+ watch: {
+ oidcProvider: {
+ immediate: true,
+ handler(provider) {
+ if (provider && this.isSetup) {
+ this.applyOidcProvider(provider)
+ }
+ },
+ },
+ },
+
methods: {
+ applyOidcProvider(provider) {
+ this.manualConfig.imapHost = provider.imapHost
+ this.manualConfig.imapPort = provider.imapPort
+ this.manualConfig.imapSslMode = provider.imapSslMode
+ this.manualConfig.imapUser = this.emailAddress
+ this.manualConfig.smtpHost = provider.smtpHost
+ this.manualConfig.smtpPort = provider.smtpPort
+ this.manualConfig.smtpSslMode = provider.smtpSslMode
+ this.manualConfig.smtpUser = this.emailAddress
+ },
+
+ async requestOauthConsent(account, isUpdate) {
+ this.loadingMessage = t('mail', 'Awaiting user consent')
+ const state = await generateOauthState(account.id)
+ let url
+ if (this.isGoogleAccount) {
+ this.feedback = isUpdate
+ ? t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Google account')
+ : t('mail', 'Account created. Please follow the pop-up instructions to link your Google account')
+ url = this.googleOauthUrl
+ .replace('_state_', state)
+ .replace('_email_', encodeURIComponent(account.emailAddress))
+ } else if (this.isMicrosoftAccount) {
+ this.feedback = isUpdate
+ ? t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Microsoft account')
+ : t('mail', 'Account created. Please follow the pop-up instructions to link your Microsoft account')
+ url = this.microsoftOauthUrl
+ .replace('_state_', state)
+ .replace('_email_', encodeURIComponent(account.emailAddress))
+ } else {
+ const provider = this.oidcProvider.name || this.oidcProvider.emailDomain
+ this.feedback = isUpdate
+ ? t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your {provider} account', { provider })
+ : t('mail', 'Account created. Please follow the pop-up instructions to link your {provider} account', { provider })
+ url = this.oidcProvider.authorizeUrl.replace('_state_', state)
+ }
+ await getUserConsent(url)
+ },
+
onModeChanged(e) {
this.mode = e.tab.id
@@ -595,7 +682,9 @@ export default {
this.loading = true
try {
if (this.mode === 'auto') {
- if (!await this.detectConfig()) {
+ if (this.isOidcAccount) {
+ this.applyOidcProvider(this.oidcProvider)
+ } else if (!await this.detectConfig()) {
logger.warn('Auto mode failed')
return
}
@@ -627,19 +716,8 @@ export default {
if (!this.account) {
const account = await this.mainStore.startAccountSetup(data)
if (this.useOauth) {
- this.loadingMessage = t('mail', 'Awaiting user consent')
try {
- if (this.isGoogleAccount) {
- this.feedback = t('mail', 'Account created. Please follow the pop-up instructions to link your Google account')
- await getUserConsent(this.googleOauthUrl
- .replace('_state_', await generateOauthState(account.id))
- .replace('_email_', encodeURIComponent(account.emailAddress)))
- } else {
- this.feedback = t('mail', 'Account created. Please follow the pop-up instructions to link your Microsoft account')
- await getUserConsent(this.microsoftOauthUrl
- .replace('_state_', await generateOauthState(account.id))
- .replace('_email_', encodeURIComponent(account.emailAddress)))
- }
+ await this.requestOauthConsent(account, false)
} catch (e) {
// Clean up the temporary account before we continue
this.mainStore.deleteAccount(account)
@@ -658,19 +736,8 @@ export default {
accountId: this.account.id,
})
if (this.useOauth) {
- this.loadingMessage = t('mail', 'Awaiting user consent')
try {
- if (this.isGoogleAccount) {
- this.feedback = t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Google account')
- await getUserConsent(this.googleOauthUrl
- .replace('_state_', await generateOauthState(account.id))
- .replace('_email_', encodeURIComponent(account.emailAddress)))
- } else {
- this.feedback = t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Microsoft account')
- await getUserConsent(this.microsoftOauthUrl
- .replace('_state_', await generateOauthState(account.id))
- .replace('_email_', encodeURIComponent(account.emailAddress)))
- }
+ await this.requestOauthConsent(account, true)
} catch (e) {
// Undo changes
await this.mainStore.updateAccount({
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..5198b9094b
--- /dev/null
+++ b/src/components/settings/OidcAdminSettings.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+ {{
+ 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..9ab3b24ac9
--- /dev/null
+++ b/src/components/settings/OidcProviderForm.vue
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/service/OidcIntegrationService.js b/src/service/OidcIntegrationService.js
new file mode 100644
index 0000000000..6bdb95c86a
--- /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 function getOidcProviders() {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers')
+ return axios.get(url).then((resp) => resp.data)
+}
+
+export function createOidcProvider(provider) {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers')
+ return axios.post(url, { data: provider }).then((resp) => resp.data)
+}
+
+export function updateOidcProvider(provider) {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers/{id}', {
+ id: provider.id,
+ })
+ return axios.post(url, { data: provider }).then((resp) => resp.data)
+}
+
+export function deleteOidcProvider(id) {
+ const url = generateUrl('/apps/mail/api/integration/oidc/providers/{id}', {
+ id,
+ })
+ return axios.delete(url).then((resp) => resp.data)
+}
diff --git a/tests/Unit/Controller/OidcIntegrationControllerTest.php b/tests/Unit/Controller/OidcIntegrationControllerTest.php
new file mode 100644
index 0000000000..c08f020450
--- /dev/null
+++ b/tests/Unit/Controller/OidcIntegrationControllerTest.php
@@ -0,0 +1,237 @@
+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_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testCreateGenericFailure(): void {
+ $this->oidcIntegration->method('createProvider')
+ ->willThrowException(new \RuntimeException('db down'));
+
+ $response = $this->controller->create(['name' => 'x']);
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testUpdateGenericFailure(): void {
+ $this->oidcIntegration->method('updateProvider')
+ ->willThrowException(new \RuntimeException('db down'));
+
+ $response = $this->controller->update(1, ['name' => 'x']);
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ 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_BAD_REQUEST, $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 \Exception('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..e91d650f0c
--- /dev/null
+++ b/tests/Unit/Db/OidcProviderMapperTest.php
@@ -0,0 +1,193 @@
+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 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/OidcIntegrationTest.php b/tests/Unit/Integration/OidcIntegrationTest.php
new file mode 100644
index 0000000000..773f3edbaf
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegrationTest.php
@@ -0,0 +1,573 @@
+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,
+ );
+ }
+
+ private function account(string $email, string $authMethod = 'xoauth2'): Account {
+ $mailAccount = new MailAccount();
+ $mailAccount->setEmail($email);
+ $mailAccount->setAuthMethod($authMethod);
+ return new Account($mailAccount);
+ }
+
+ private 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;
+ }
+
+ public function testGetProviderForAccountMatchesOnDomain(): void {
+ $provider = $this->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')));
+ }
+
+ public function testGetDiscoveryUsesCache(): void {
+ $provider = $this->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 testFinishConnectStoresTokens(): void {
+ $provider = $this->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 testRefreshNoopWhenNotAuthorized(): void {
+ $account = $this->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 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 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 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 testGetProvidersDelegatesToMapper(): void {
+ $this->providerMapper->expects($this->once())
+ ->method('getAll')
+ ->willReturn([$this->provider()]);
+
+ $this->assertCount(1, $this->integration->getProviders());
+ }
+
+ 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(\InvalidArgumentException::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);
+ }
+
+ 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 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 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 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 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 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);
+ }
+}
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()
From 634aec010570ac1a3cf7c10212cadd1285488296 Mon Sep 17 00:00:00 2001
From: Leah
Date: Mon, 20 Jul 2026 01:49:45 +0200
Subject: [PATCH 2/7] feat: ask the user to reconnect when an OIDC grant can no
longer be renewed
A refresh token eventually expires or is revoked, for example after an extended
outage where nothing synced, and OAuth offers no way to know that lifetime up
front. Until now the refresh simply failed, background sync quietly stopped and
the user was left to work out that the account needed attention.
A failed refresh is no longer trusted on its own: the provider is asked through
its introspection endpoint (RFC 7662) whether the refresh token is still active.
Only a definitive "no", or having no refresh token at all while the access token
has expired, marks the account as needing re-authentication, so a provider that
is merely unreachable is still treated as a temporary problem. The flag is
cleared again as soon as any refresh or reconnect succeeds.
A flagged account raises a dialog when Mail is opened, which reconnects it
through the same consent popup used during setup and then pulls the mailbox list
that could not sync while the grant was dead. The popup is opened from the button
press so it keeps the user activation browsers require. A broken OAuth account
has no password to change, so the navigation error offers to reconnect instead.
The introspection endpoint is read from the discovery document, or can be entered
by hand next to the other endpoints for providers configured that way. Without one
a rejected refresh is never treated as a dead grant.
Also splits the OidcIntegration unit tests into per-behaviour classes sharing a
common fixture, as the single test class had grown unwieldy.
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah
---
doc/oidc-xoauth2.md | 24 +-
lib/Db/MailAccount.php | 5 +
lib/Db/OidcProvider.php | 4 +
lib/Db/OidcProviderMapper.php | 5 +
lib/Integration/OidcIntegration.php | 84 ++-
.../Version5300Date20260717120000.php | 16 +
src/components/Navigation.vue | 17 +-
src/components/OidcReauthDialog.vue | 139 +++++
src/components/settings/OidcProviderForm.vue | 18 +
src/views/Home.vue | 4 +
.../OidcAccountMatchingTest.php | 53 ++
.../OidcIntegration/OidcConnectTest.php | 140 +++++
.../OidcIntegration/OidcEndpointsTest.php | 163 +++++
.../OidcIntegrationTestCase.php | 128 ++++
.../OidcIntegration/OidcProviderCrudTest.php | 96 +++
.../OidcIntegration/OidcReauthTest.php | 86 +++
.../OidcIntegration/OidcRefreshTest.php | 157 +++++
.../Unit/Integration/OidcIntegrationTest.php | 573 ------------------
18 files changed, 1134 insertions(+), 578 deletions(-)
create mode 100644 src/components/OidcReauthDialog.vue
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcAccountMatchingTest.php
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcConnectTest.php
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcEndpointsTest.php
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcIntegrationTestCase.php
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcProviderCrudTest.php
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcReauthTest.php
create mode 100644 tests/Unit/Integration/OidcIntegration/OidcRefreshTest.php
delete mode 100644 tests/Unit/Integration/OidcIntegrationTest.php
diff --git a/doc/oidc-xoauth2.md b/doc/oidc-xoauth2.md
index 7e08e85ccc..050ebe0590 100644
--- a/doc/oidc-xoauth2.md
+++ b/doc/oidc-xoauth2.md
@@ -93,7 +93,29 @@ sure the access token it issues is accepted by your IMAP/SMTP server for `XOAUTH
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.
+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
diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php
index f3e8b900cb..be8bba5108 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()
@@ -135,6 +137,7 @@ class MailAccount extends Entity {
protected $oauthAccessToken;
protected $oauthRefreshToken;
protected $oauthTokenTtl;
+ protected $oauthNeedsReauth;
/** @var int|null */
protected $draftsMailboxId;
@@ -267,6 +270,7 @@ public function __construct(array $params = []) {
$this->addType('provisioningId', 'integer');
$this->addType('order', 'integer');
$this->addType('showSubscribedOnly', 'boolean');
+ $this->addType('oauthNeedsReauth', 'boolean');
$this->addType('personalNamespace', 'string');
$this->addType('draftsMailboxId', 'integer');
$this->addType('sentMailboxId', 'integer');
@@ -326,6 +330,7 @@ public function toJson() {
'archiveMailboxId' => $this->getArchiveMailboxId(),
'snoozeMailboxId' => $this->getSnoozeMailboxId(),
'sieveEnabled' => ($this->isSieveEnabled() === true),
+ 'oauthNeedsReauth' => ($this->getOauthNeedsReauth() === true),
'signatureAboveQuote' => ($this->isSignatureAboveQuote() === true),
'signatureMode' => $this->getSignatureMode(),
'smimeCertificateId' => $this->getSmimeCertificateId(),
diff --git a/lib/Db/OidcProvider.php b/lib/Db/OidcProvider.php
index a53cc8de48..3d38524447 100644
--- a/lib/Db/OidcProvider.php
+++ b/lib/Db/OidcProvider.php
@@ -45,6 +45,8 @@
* @method void setAuthorizationEndpoint(string $authorizationEndpoint)
* @method string getTokenEndpoint()
* @method void setTokenEndpoint(string $tokenEndpoint)
+ * @method string getIntrospectionEndpoint()
+ * @method void setIntrospectionEndpoint(string $introspectionEndpoint)
* @method string getScope()
* @method void setScope(string $scope)
*/
@@ -65,6 +67,7 @@ class OidcProvider extends Entity implements JsonSerializable {
protected $manualEndpoints;
protected $authorizationEndpoint;
protected $tokenEndpoint;
+ protected $introspectionEndpoint;
protected $scope;
public function __construct() {
@@ -96,6 +99,7 @@ public function jsonSerialize() {
'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
index 29c56eaeb9..c1cc0d0920 100644
--- a/lib/Db/OidcProviderMapper.php
+++ b/lib/Db/OidcProviderMapper.php
@@ -121,6 +121,11 @@ public function validate(array $data): OidcProvider {
$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']
diff --git a/lib/Integration/OidcIntegration.php b/lib/Integration/OidcIntegration.php
index 99a11e1385..57746e72af 100644
--- a/lib/Integration/OidcIntegration.php
+++ b/lib/Integration/OidcIntegration.php
@@ -200,14 +200,20 @@ public function getDiscovery(OidcProvider $provider): array {
* Resolve the provider's authorization and token endpoints, either from the
* admin-entered manual values or from its discovery document.
*
- * @return array{authorization_endpoint: string, token_endpoint: string}
+ * 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,
];
}
@@ -215,9 +221,56 @@ public function getEndpoints(OidcProvider $provider): array {
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.
*
@@ -268,6 +321,7 @@ public function finishConnect(OidcProvider $provider, Account $account, string $
}
$account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($data['access_token']));
$account->getMailAccount()->setOauthTokenTtl($this->timeFactory->getTime() + $data['expires_in']);
+ $account->getMailAccount()->setOauthNeedsReauth(false);
return $account;
}
@@ -277,7 +331,7 @@ public function finishConnect(OidcProvider $provider, Account $account, string $
*/
public function refresh(Account $account): Account {
$oauthRefreshToken = $account->getMailAccount()->getOauthRefreshToken();
- if ($account->getMailAccount()->getOauthTokenTtl() === null || $oauthRefreshToken === null) {
+ if ($account->getMailAccount()->getOauthTokenTtl() === null) {
// Account is not authorized yet
return $account;
}
@@ -296,6 +350,16 @@ public function refresh(Account $account): Account {
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 {
$tokenEndpoint = $this->getEndpoints($provider)['token_endpoint'];
} catch (Exception $e) {
@@ -327,12 +391,28 @@ public function refresh(Account $account): Account {
'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.
+ $active = $this->introspectToken(
+ $provider,
+ $this->crypto->decrypt($oauthRefreshToken),
+ 'refresh_token',
+ );
+ if ($active === 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;
}
$data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
$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']));
diff --git a/lib/Migration/Version5300Date20260717120000.php b/lib/Migration/Version5300Date20260717120000.php
index 9dfa6a51de..d4ba180511 100644
--- a/lib/Migration/Version5300Date20260717120000.php
+++ b/lib/Migration/Version5300Date20260717120000.php
@@ -104,6 +104,11 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
'length' => 2048,
'default' => '',
]);
+ $table->addColumn('introspection_endpoint', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 2048,
+ 'default' => '',
+ ]);
$table->addColumn('scope', Types::STRING, [
'notnull' => true,
'length' => 255,
@@ -113,6 +118,17 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
$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/src/components/Navigation.vue b/src/components/Navigation.vue
index cce5e72694..b844acaaca 100644
--- a/src/components/Navigation.vue
+++ b/src/components/Navigation.vue
@@ -36,8 +36,8 @@
{{ 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..005f016550
--- /dev/null
+++ b/src/components/OidcReauthDialog.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+ {{
+ 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/OidcProviderForm.vue b/src/components/settings/OidcProviderForm.vue
index 9ab3b24ac9..9e5210069f 100644
--- a/src/components/settings/OidcProviderForm.vue
+++ b/src/components/settings/OidcProviderForm.vue
@@ -112,6 +112,16 @@
:label="t('mail', 'Token endpoint')"
:disabled="loading"
placeholder="https://idp.example.com/token" />
+
+
+ {{ t('mail', 'Used to confirm that a rejected token really expired, so the user can be asked to reconnect. Without it, a failed refresh is always treated as a temporary error.') }}
+
+
+
@@ -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,
},
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..4e03e2a303
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcConnectTest.php
@@ -0,0 +1,140 @@
+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());
+ }
+}
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..0375af517e
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcProviderCrudTest.php
@@ -0,0 +1,96 @@
+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(\InvalidArgumentException::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..c14c6e75dd
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegration/OidcRefreshTest.php
@@ -0,0 +1,157 @@
+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());
+ }
+}
diff --git a/tests/Unit/Integration/OidcIntegrationTest.php b/tests/Unit/Integration/OidcIntegrationTest.php
deleted file mode 100644
index 773f3edbaf..0000000000
--- a/tests/Unit/Integration/OidcIntegrationTest.php
+++ /dev/null
@@ -1,573 +0,0 @@
-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,
- );
- }
-
- private function account(string $email, string $authMethod = 'xoauth2'): Account {
- $mailAccount = new MailAccount();
- $mailAccount->setEmail($email);
- $mailAccount->setAuthMethod($authMethod);
- return new Account($mailAccount);
- }
-
- private 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;
- }
-
- public function testGetProviderForAccountMatchesOnDomain(): void {
- $provider = $this->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')));
- }
-
- public function testGetDiscoveryUsesCache(): void {
- $provider = $this->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 testFinishConnectStoresTokens(): void {
- $provider = $this->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 testRefreshNoopWhenNotAuthorized(): void {
- $account = $this->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 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 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 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 testGetProvidersDelegatesToMapper(): void {
- $this->providerMapper->expects($this->once())
- ->method('getAll')
- ->willReturn([$this->provider()]);
-
- $this->assertCount(1, $this->integration->getProviders());
- }
-
- 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(\InvalidArgumentException::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);
- }
-
- 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 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 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 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 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 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);
- }
-}
From 787b6994cc03501a8a5197a9671d6f51e9bb96b5 Mon Sep 17 00:00:00 2001
From: Leah
Date: Mon, 20 Jul 2026 13:06:24 +0200
Subject: [PATCH 3/7] feat: surface the OIDC reconnect dialog after a failed
connection
Until now the reconnect dialog only appeared when an account was already flagged
for re-authentication in the initial page state. When Mail tests each account's
connection on load it creates an IMAP client, which runs the token refresh on the
server and may flag the account then and there, so re-read that flag afterwards to
let the dialog show in the same session.
Because the connection test uses a lazily-connecting client it can return success
even when the grant is dead, so the flag is re-read for every OIDC account rather
than only on a reported failure. Only the single flag is patched to avoid
re-adding the account to the list.
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah
---
src/store/mainStore/actions.js | 13 +++++++++++++
src/views/Home.vue | 5 +++++
2 files changed, 18 insertions(+)
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/views/Home.vue b/src/views/Home.vue
index b992544945..33cd2b26e5 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -94,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)
}
},
From 29729989a5575cd1b7895bbaa09d18c39f76b209 Mon Sep 17 00:00:00 2001
From: Leah
Date: Mon, 20 Jul 2026 13:12:33 +0200
Subject: [PATCH 4/7] test: include oauthNeedsReauth in the MailAccount
serialization assertions
The new oauth_needs_reauth flag is exposed by MailAccount::toJson(), so the two
integration tests asserting the full serialized array need it too.
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah
---
tests/Integration/Db/MailAccountTest.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php
index 0d2efc5bae..8e0eb014a9 100644
--- a/tests/Integration/Db/MailAccountTest.php
+++ b/tests/Integration/Db/MailAccountTest.php
@@ -74,6 +74,7 @@ public function testToAPI() {
'classificationEnabled' => true,
'authMethod' => 'password',
'imipCreate' => false,
+ 'oauthNeedsReauth' => false,
], $a->toJson());
}
@@ -115,6 +116,7 @@ public function testMailAccountConstruct() {
'classificationEnabled' => true,
'authMethod' => 'password',
'imipCreate' => false,
+ 'oauthNeedsReauth' => false,
];
$a = new MailAccount($expected);
// TODO: fix inconsistency
From 66d05ad09a9cf07852c5a38e6d8b3d9928ac0267 Mon Sep 17 00:00:00 2001
From: Leah
Date: Mon, 20 Jul 2026 13:46:05 +0200
Subject: [PATCH 5/7] refactor: improvements and clean-up after self-review
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah
---
lib/Db/OidcProviderMapper.php | 4 +-
lib/Integration/OidcIntegration.php | 41 ++++++++++++----
src/components/AccountForm.vue | 27 ++++-------
src/components/OidcReauthDialog.vue | 38 +++++++--------
src/components/settings/OidcAdminSettings.vue | 3 +-
src/util/oidc.js | 47 +++++++++++++++++++
tests/Unit/Db/OidcProviderMapperTest.php | 9 ++++
.../OidcIntegration/OidcConnectTest.php | 18 +++++++
.../OidcIntegration/OidcRefreshTest.php | 34 ++++++++++++++
9 files changed, 173 insertions(+), 48 deletions(-)
create mode 100644 src/util/oidc.js
diff --git a/lib/Db/OidcProviderMapper.php b/lib/Db/OidcProviderMapper.php
index c1cc0d0920..e8fb68e1c7 100644
--- a/lib/Db/OidcProviderMapper.php
+++ b/lib/Db/OidcProviderMapper.php
@@ -109,7 +109,9 @@ public function validate(array $data): OidcProvider {
$provider->setId((int)$data['id']);
}
$provider->setName($data['name']);
- $provider->setEmailDomain($data['emailDomain']);
+ // 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']);
diff --git a/lib/Integration/OidcIntegration.php b/lib/Integration/OidcIntegration.php
index 57746e72af..7c2021495e 100644
--- a/lib/Integration/OidcIntegration.php
+++ b/lib/Integration/OidcIntegration.php
@@ -308,6 +308,7 @@ public function finishConnect(OidcProvider $provider, Account $account, string $
'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,
@@ -315,7 +316,13 @@ public function finishConnect(OidcProvider $provider, Account $account, string $
return $account;
}
- $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ 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']));
}
@@ -360,6 +367,20 @@ public function refresh(Account $account): Account {
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) {
@@ -373,7 +394,7 @@ public function refresh(Account $account): Account {
$body = [
'client_id' => $provider->getClientId(),
'grant_type' => 'refresh_token',
- 'refresh_token' => $this->crypto->decrypt($oauthRefreshToken),
+ 'refresh_token' => $refreshToken,
];
$clientSecret = $this->getClientSecret($provider);
if ($clientSecret !== null) {
@@ -386,6 +407,7 @@ public function refresh(Account $account): Account {
'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,
@@ -395,12 +417,7 @@ public function refresh(Account $account): Account {
// 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.
- $active = $this->introspectToken(
- $provider,
- $this->crypto->decrypt($oauthRefreshToken),
- 'refresh_token',
- );
- if ($active === false) {
+ 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(),
]);
@@ -409,7 +426,13 @@ public function refresh(Account $account): Account {
return $account;
}
- $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ 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);
diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue
index b83b2fe26a..09e65bc119 100644
--- a/src/components/AccountForm.vue
+++ b/src/components/AccountForm.vue
@@ -295,6 +295,7 @@ import {
} from '../service/AutoConfigService.js'
import { generateOauthState } from '../service/OauthStateService.js'
import useMainStore from '../store/mainStore.js'
+import { findProviderForEmail, loadOidcProviders, openOidcConsent } from '../util/oidc.js'
export default {
name: 'AccountForm',
@@ -362,7 +363,7 @@ export default {
feedback: null,
password: '',
- oidcProviders: loadState('mail', 'oidc_providers', []),
+ oidcProviders: loadOidcProviders(),
}
},
@@ -434,12 +435,7 @@ export default {
* @return {?object}
*/
oidcProvider() {
- const at = this.emailAddress.lastIndexOf('@')
- if (at === -1) {
- return null
- }
- const domain = this.emailAddress.slice(at + 1).toLowerCase()
- return this.oidcProviders.find((p) => (p.emailDomain || '').toLowerCase() === domain) ?? null
+ return findProviderForEmail(this.emailAddress, this.oidcProviders)
},
isOidcAccount() {
@@ -503,30 +499,27 @@ export default {
async requestOauthConsent(account, isUpdate) {
this.loadingMessage = t('mail', 'Awaiting user consent')
- const state = await generateOauthState(account.id)
- let url
if (this.isGoogleAccount) {
this.feedback = isUpdate
? t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Google account')
: t('mail', 'Account created. Please follow the pop-up instructions to link your Google account')
- url = this.googleOauthUrl
- .replace('_state_', state)
- .replace('_email_', encodeURIComponent(account.emailAddress))
+ await getUserConsent(this.googleOauthUrl
+ .replace('_state_', await generateOauthState(account.id))
+ .replace('_email_', encodeURIComponent(account.emailAddress)))
} else if (this.isMicrosoftAccount) {
this.feedback = isUpdate
? t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Microsoft account')
: t('mail', 'Account created. Please follow the pop-up instructions to link your Microsoft account')
- url = this.microsoftOauthUrl
- .replace('_state_', state)
- .replace('_email_', encodeURIComponent(account.emailAddress))
+ await getUserConsent(this.microsoftOauthUrl
+ .replace('_state_', await generateOauthState(account.id))
+ .replace('_email_', encodeURIComponent(account.emailAddress)))
} else {
const provider = this.oidcProvider.name || this.oidcProvider.emailDomain
this.feedback = isUpdate
? t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your {provider} account', { provider })
: t('mail', 'Account created. Please follow the pop-up instructions to link your {provider} account', { provider })
- url = this.oidcProvider.authorizeUrl.replace('_state_', state)
+ await openOidcConsent(this.oidcProvider, account.id)
}
- await getUserConsent(url)
},
onModeChanged(e) {
diff --git a/src/components/OidcReauthDialog.vue b/src/components/OidcReauthDialog.vue
index 005f016550..2a8658ed54 100644
--- a/src/components/OidcReauthDialog.vue
+++ b/src/components/OidcReauthDialog.vue
@@ -5,11 +5,11 @@
+ @closing="dismiss">
{{
t(
@@ -24,14 +24,13 @@