Skip to content

Cannot reconnect an OAuth account after its refresh token expires: updateAccount() syncs mailboxes before getUserConsent() runs #13270

Description

@scot-x

Steps to reproduce

  1. Configure Microsoft (Azure) XOAUTH2 in the Mail admin settings and connect an Outlook/Microsoft 365 account. Confirm it syncs.
  2. Cause the account's OAuth refresh token to become unusable and leave it that way long enough for Microsoft to expire it. In practice this happens on its own: let the Azure client secret expire (max lifetime 24 months). Sync then fails on every refresh attempt, and after ~90 days of inactivity Microsoft expires the refresh token itself (AADSTS700082: The refresh token has expired due to inactivity).
  3. Install a valid client secret again (admin settings → Mail → Microsoft integration). The secret is now good; only the account's refresh token is dead.
  4. Open Mail → Account settings for that account → Mail serverManual tab.
  5. Click Reconnect Microsoft account.

Expected behavior

The Microsoft consent pop-up opens, and completing it stores a fresh access/refresh token pair for the account.

Actual behavior

No pop-up ever opens. The form shows the generic error:

There was an error while setting up your account

Nothing is written to nextcloud.log, because the failure is entirely client-side.

Network trace for the single button click:

PUT /apps/mail/api/accounts/5                        -> 200   (account update succeeds)
GET /apps/mail/api/mailboxes?accountId=5&forceSync=true -> 500 (IMAP sync; token is dead)

Console:

[ERROR] mail: could not save account details
AxiosError: Request failed with status code 500  (ERR_BAD_RESPONSE)
  response.config.url = "/apps/mail/api/mailboxes?accountId=5&forceSync=true"
  at onSubmit (AccountForm.vue)

The AxiosError is not from the PUT — that returns 200. It comes from the mailbox sync that updateAccount() performs immediately afterwards.

Cause

onSubmit() awaits updateAccount() before it calls getUserConsent():

const oldAccountData = this.account
const account = await this.mainStore.updateAccount({
...data,
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)))
}

const oldAccountData = this.account
const account = await this.mainStore.updateAccount({
    ...data,
    accountId: this.account.id,
})
if (this.useOauth) {                       // never reached: the await above throws
    this.loadingMessage = t('mail', 'Awaiting user consent')
    ...
    await getUserConsent(this.microsoftOauthUrl
        .replace('_state_', await generateOauthState(account.id))
        .replace('_email_', encodeURIComponent(account.emailAddress)))
}

and the updateAccount store action synchronises mailboxes as its last step:

async updateAccount(config) {
return handleHttpAuthErrors(async () => {
const account = await updateAccount(config)
logger.debug('account updated', { account })
this.editAccountMutation({ ...account, error: false })
await this.syncMailboxesForAccount(this.accountsUnmapped[account.id])
return account
})
},

async updateAccount(config) {
    return handleHttpAuthErrors(async () => {
        const account = await updateAccount(config)
        logger.debug('account updated', { account })
        this.editAccountMutation({ ...account, error: false })
        await this.syncMailboxesForAccount(this.accountsUnmapped[account.id])   // <-- needs a valid token
        return account
    })
}

syncMailboxesForAccount() performs an IMAP login, which requires a valid OAuth access token. When the refresh token is dead, Nextcloud cannot mint one, the request 500s, the promise rejects, the catch in onSubmit() rolls the account back, and getUserConsent() is never invoked.

This is circular: the only UI path to obtain a new token first requires the token you are trying to replace. Once an account reaches this state the button can never succeed, no matter how many times it is clicked.

Note this is distinct from #8641 / #8644 (reconnect button used the Google OAuth URL for Microsoft accounts, fixed in 2024). The user-visible message is the same, the cause is not. AccountForm.vue is otherwise correct here — the label reads "Reconnect Microsoft account" and microsoftOauthUrl is used.

Workaround

The consent URL is built client-side from initial state plus a server-minted, single-use state nonce, so the broken form can be bypassed entirely. Run in the browser console while logged in as the account's user, then complete the Microsoft sign-in:

const oauthUrl = JSON.parse(atob(
    document.querySelector('#initial-state-mail-microsoft-oauth-url').value))
const res = await fetch('/apps/mail/api/oauth/state', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        requesttoken: OC.requestToken,
        'X-Requested-With': 'XMLHttpRequest',
    },
    body: JSON.stringify({ accountId: ACCOUNT_ID }),
})
const { data: { state } } = await res.json()
location.href = oauthUrl
    .replace('_state_', state)
    .replace('_email_', encodeURIComponent('user@example.com'))

Using a top-level navigation rather than window.open() also avoids pop-up blocking. After consent, oauth_token_ttl and oauth_access_token in oc_mail_accounts are rewritten and sync resumes.

Suggested fix

Either:

  • call getUserConsent() before updateAccount() when this.useOauth is true, or
  • let updateAccount() tolerate a failing syncMailboxesForAccount() — the mailbox sync is a convenience refresh, not part of persisting the account, and a reconnect is precisely the moment it is expected to fail.

Mail app version

5.10.7

Nextcloud version

No response

Mailserver or service

Microsoft 365 (outlook.office365.com, XOAUTH2, tenant common)

Operating system

Debian (Nextcloud All-in-One container)

PHP engine version

PHP 8.3

Nextcloud memory caching

No response

Web server

Apache (supported)

Database

PostgreSQL

Additional info

Code references above are against main at a406c97ecacd25b220386c37c29adfe5bbed0d1a (2026-07-08); the ordering is unchanged there, so this is not specific to 5.10.7. Reproduced with Firefox 153 and Chrome; pop-up blocking is not a factor, as the pop-up is never reached.

Confirmation that the account state was the only problem: after completing consent via the workaround above, occ mail:account:diagnose <id> reported IMAP capabilities and 19,540 messages across 139 mailboxes with no other change, using the same client secret the failing form had.

A related but independent defect, filed separately: oauthRedirect() accepts an $error parameter and never reads it, so a provider-returned error renders the same "Account connected" page as success and logs nothing. That is what initially made the failing reconnect attempts look as though they had worked.

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions