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') }}