Cannot reconnect an OAuth account after its refresh token expires: `updateAccount()` syncs mailboxes before `getUserConsent()` runs
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 1k
- Forks
- 348
- Avg merge
- 12h 28m
- Merged PRs (30d)
- 91
Description
Steps to reproduce
- Configure Microsoft (Azure) XOAUTH2 in the Mail admin settings and connect an Outlook/Microsoft 365 account. Confirm it syncs.
- 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). - Install a valid client secret again (admin settings → Mail → Microsoft integration). The secret is now good; only the account's refresh token is dead.
- Open Mail → Account settings for that account → Mail server → Manual tab.
- 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) { // 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]) // <-- 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()beforeupdateAccount()whenthis.useOauthis true, or - let
updateAccount()tolerate a failingsyncMailboxesForAccount()— 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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