nextcloud / nextcloud/mail

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

Open
#13,270 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

1. to develop bug
Dominant language
JavaScript
Stars
1k
Forks
348
Avg merge
12h 28m
Merged PRs (30d)
91

Description

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():

https://github.com/nextcloud/mail/blob/a406c97ecacd25b220386c37c29adfe5bbed0d1a/src/components/AccountForm.vue#L655-L673

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:

https://github.com/nextcloud/mail/blob/a406c97ecacd25b220386c37c29adfe5bbed0d1a/src/store/mainStore/actions.js#L264-L272

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/components/AccountForm.vue around onSubmit() and src/store/mainStore/actions.js around updateAccount(). Reproduce reconnect with an unusable refresh token, then trace the update and mailbox-sync requests. Done means the Microsoft consent flow opens and can save fresh tokens even when the pre-reconnect mailbox sync fails.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
authentication, frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.