Skip to content

feat: generic OIDC/XOAUTH2 support for individual mail accounts - #13317

Open
LeahAuroraV wants to merge 23 commits into
nextcloud:mainfrom
LeahAuroraV:feat/oidc-individual
Open

LeahAuroraV wants to merge 23 commits into
nextcloud:mainfrom
LeahAuroraV:feat/oidc-individual

Conversation

@LeahAuroraV

@LeahAuroraV LeahAuroraV commented Jul 18, 2026

Copy link
Copy Markdown

Fixes #12491

Individual OIDC / XOAUTH2 support

Follow-up to #13311. In the review Christoph suggested "getting individual OIDC support in first and closing #12491", and then building the provisioning on top of that. So here's generic oidc for nextcloud mail.

It's basically the same idea as the Google/Microsoft integrations that already exist, just generalised to any OIDC provider. An admin registers one or more providers in the groupware admin settings (client id + secret, a discovery url or the endpoints directly, imap/smtp servers). When a user adds a mail account whose email domain matches one of those, mail pre-fills the servers and opens the consent popup, the code gets exchanged server-side and the account authenticates over XOAUTH2 with no stored password. tokens get refreshed via the stored refresh token for background/cron sync, same as google/ms.

Testing

Developed against authentik with dovecot + postfix set up to accept XOAUTH2. Verified the whole thing locally: add account -> popup -> consent -> imap sync + smtp send, then force-expire the token and confirm background refresh still works.
There's a documentation file that described how to get a test envionment up and running in doc/oidc-xoauth2.md.

Changes

  • New mail_oidc_providers table + OidcProvider entity/mapper (email domain, separate imap/smtp host/port/ssl, client id/secret, discovery url or manual endpoints, scopes)
  • OidcIntegration service: discovery fetch + cache (or manual endpoints), auth-code exchange, token refresh, provider CRUD (secret encrypted at rest)
  • OidcIntegrationController + routes: admin CRUD, the authorize redirect (resolves discovery server-side and 302s to the idp) and the oauth callback, reusing OauthStateService
  • OauthTokenRefreshListener: refresh the oidc token before imap connect, next to the google/ms branches
  • Admin UI to manage providers + AccountForm email-domain detection -> pre-fill + sso popup
  • Docs + Unit Tests

closes #12491

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

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 nextcloud#12491

Assisted-by: Claude:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Leah <leahvenema@hotmail.com>
@welcome

welcome Bot commented Jul 18, 2026

Copy link
Copy Markdown

Thanks for opening your first pull request in this repository! ✌️

@LeahAuroraV
LeahAuroraV marked this pull request as draft July 19, 2026 23:08
@LeahAuroraV

Copy link
Copy Markdown
Author

Adding some functionality to handle expired, revoked or non-existent refresh tokens first before this is ready for review

…enewed

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 <leahvenema@hotmail.com>
@ChristophWurst

Copy link
Copy Markdown
Member

Thanks for looking into this 🙏

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 <leahvenema@hotmail.com>
…tions

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 <leahvenema@hotmail.com>
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah <leahvenema@hotmail.com>
@LeahAuroraV
LeahAuroraV marked this pull request as ready for review July 20, 2026 11:49

@ChristophWurst ChristophWurst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick manual scan for code smells

Comment thread appinfo/routes.php Outdated
Comment thread lib/Controller/OidcIntegrationController.php Outdated
Comment thread lib/Controller/OidcIntegrationController.php Outdated
Comment thread lib/Integration/OidcIntegration.php
Comment thread lib/Migration/Version5300Date20260717120000.php Outdated
Comment thread src/service/OidcIntegrationService.js Outdated
@ChristophWurst ChristophWurst self-assigned this Jul 21, 2026
Signed-off-by: Leah <leahvenema@hotmail.com>
Validation errors now answer 422 instead of 400, the authorize endpoint only
catches ServiceException, and updating a provider without an id raises
OidcProviderNotFoundException, so the assertions are updated to match.

Unexpected errors are no longer caught by the controller either, as TrapError
handles them, so the two tests covering that path now assert the exception
propagates rather than expecting a response.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah <leahvenema@hotmail.com>
@LeahAuroraV

Copy link
Copy Markdown
Author

@ChristophWurst thanks for the review! I've addressed your feedback.

The new exception class was missing the SPDX tags the REUSE check requires and
used a brace style php-cs-fixer rejects.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Leah <leahvenema@hotmail.com>
@LeahAuroraV

Copy link
Copy Markdown
Author

Do you want me to fix the commit message that is being flagged by the workflow? As it would require a force push.

@ChristophWurst

Copy link
Copy Markdown
Member

Only after positive review and when all other CI checks have passed

# Conflicts:
#	lib/Controller/PageController.php
#	lib/Settings/AdminSettings.php
#	tests/Unit/Controller/PageControllerTest.php
Signed-off-by: Leah <leahvenema@hotmail.com>
@LeahAuroraV

Copy link
Copy Markdown
Author

Alright, all other CI checks have passed. Is there anything else to do, or just wait for a more extensive review/test now?

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

@LeahAuroraV

Copy link
Copy Markdown
Author

@ChristophWurst any updates?

brandli added a commit to gentian-org/gentian-os that referenced this pull request Aug 18, 2026
App passwords are the industry's fallback, not its practice — Google ended
password auth for mail in 2022 and Microsoft ends it in December 2026. Dovecot
is already configured for the token path; the gap is Nextcloud Mail, whose
OAuth support covers hosted Google and Microsoft only.

Recorded because the gap is close to closing: nextcloud/mail#13317 implements
generic OIDC for any provider and names Keycloak, and has been awaiting
code-owner review since July. Worth tracking rather than designing around
permanently, and worth noting that the widely-linked #12483 is closed as "not
planned" but is a duplicate of the live, in-progress #12491.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/components/Navigation.vue
@ChristophWurst

Copy link
Copy Markdown
Member

Apologies for the delay. I will review and test soon.

brandli added a commit to gentian-org/gentian-apps that referenced this pull request Aug 28, 2026
The operator mints one IMAP credential per user per client application
(mail_apppassword.go, whose constant is literally "nextcloud-mail"), hashes it
for Dovecot in the kernel namespace and writes the plaintext into the tenant's
own namespace. Verified against this cluster: the credential is there and
Dovecot answers "a1 OK" to it.

Nothing consumed it. mail-app-passwords appeared in exactly two places, both
inside the reconciler that writes it, and the Secret was not mounted into the
Nextcloud pod at all -- so oc_mail_accounts stayed empty and every user was
asked to "Connect your mail account" over a credential that already worked.

The Secret is now mounted beside the two that were already there, and the
post-installation hook writes a provisioning script and runs it.

The script lives on disk rather than only in the hook because a Nextcloud user
is created on their FIRST OIDC LOGIN, which is almost never while the hook
runs. The cron sidecar re-runs it each tick, so someone who logs in later has
an inbox within a cron interval instead of at the next pod restart. It skips
any user who already has an account, so repetition costs one lookup.

IMAP security is none: Dovecot advertises no STARTTLS on 143 and 993 is not
exposed, and the hop is pod to Service inside this cluster -- the boundary the
database connection already crosses. SMTP reuses the tenant submission
credential the platform hands the app, which is what mail.md describes.

Roadmap 1.19 deletes all of this: Dovecot already advertises AUTH=XOAUTH2, and
nextcloud/mail#13317 brings the client side. mail:account:create already takes
xoauth2 as an auth-method, so that is a change of two arguments here.

Tested on the live pod: provisioned christian@corp.gentian.cloud, and a second
run made no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LeahAuroraV and others added 2 commits September 16, 2026 16:45
# Conflicts:
#	src/components/AccountForm.vue
5.12 shipped, so the migration's 5011 prefix now sorts before
released migrations and would never line up with an upgrade step.
Follow the Version5120 prefix style main switched to, which also
orders this after the 5.12 steps.

The date segment is kept as the original creation timestamp, and
appinfo/info.xml is intentionally not bumped here so the branch
stays free of release-version churn.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Leah <leahvenema@hotmail.com>
@LeahAuroraV

Copy link
Copy Markdown
Author

Apologies for the delay. I will review and test soon.

@ChristophWurst it's been a while...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth2 support for mail app

3 participants