Steps to reproduce
- Configure Microsoft (Azure) or Google XOAUTH2 in the Mail admin settings.
- Start an account authorization from Mail, so the browser is sent to the provider.
- Cause the provider to redirect back with an error rather than a
code — e.g. decline the consent screen, or let the app registration hit a Conditional Access policy. The provider then calls the redirect URI with ?error=access_denied&error_description=… and no code.
Expected behavior
The user is told the authorization failed, and the reason is recorded in nextcloud.log.
Actual behavior
The page renders "Account connected — You can close this window", identical to a successful authorization. Nothing is written to nextcloud.log. No token is stored.
The user has no way to distinguish a successful link from a failed one, and an administrator inspecting the logs afterwards finds no trace of the failure at all.
Cause
oauthRedirect() accepts an $error parameter and never reads it. Every outcome — success, missing code, unauthenticated session, and provider-returned error — renders the same oauth_done template:
|
public function oauthRedirect(?string $code, ?string $state, ?string $session_state, ?string $error): Response { |
|
if ($this->userId === null) { |
|
// TODO: redirect to main nextcloud page |
|
return new StandaloneTemplateResponse( |
|
Application::APP_ID, |
|
'oauth_done', |
|
[], |
|
'guest', |
|
); |
|
} |
|
|
|
if (!isset($code, $state)) { |
|
// TODO: handle error |
|
return new StandaloneTemplateResponse( |
|
Application::APP_ID, |
|
'oauth_done', |
|
[], |
|
'guest', |
|
); |
|
} |
public function oauthRedirect(?string $code, ?string $state, ?string $session_state, ?string $error): Response {
if ($this->userId === null) {
// TODO: redirect to main nextcloud page
return new StandaloneTemplateResponse(Application::APP_ID, 'oauth_done', [], 'guest');
}
if (!isset($code, $state)) {
// TODO: handle error
return new StandaloneTemplateResponse(Application::APP_ID, 'oauth_done', [], 'guest');
}
try {
...
$error occurs exactly three times in the file: in the docblock, in the signature, and in the // TODO: handle error comment above the branch that ignores it. A provider error therefore lands in the !isset($code, $state) branch and is discarded.
Of the four exit paths, only two log anything (invalid OAuth state warns; finishConnect() failures log). The two silent ones are the two a user is most likely to hit.
GoogleIntegrationController::oauthRedirect() has the same signature and the same structure, so this affects both providers:
|
public function oauthRedirect(?string $code, ?string $state, ?string $scope, ?string $error): Response { |
Impact
This is mostly a diagnosability problem, but a sharp one. While debugging an unrelated reconnect failure on a live instance, this behaviour cost several rounds of investigation: the "Account connected" page was taken as evidence that authorization had succeeded, when in fact no token had been written. The only reliable signal was inspecting oauth_token_ttl and oauth_access_token in oc_mail_accounts directly.
Any user hitting a declined consent screen, an expired authorization code, or a tenant Conditional Access policy will be told their account is connected when it is not.
Suggested fix
In the !isset($code, $state) branch, when $error is present:
$this->logger->warning('OAuth authorization failed', ['error' => $error, 'error_description' => $errorDescription]) — note error_description would need adding to the signature; it carries the provider's AADSTS… code, which is the actually diagnostic part.
- render an error state in the
oauth_done template rather than the success text, so the user knows to retry.
Both controllers would need the change. Passing $error through to the template is enough; it does not need to be surfaced verbatim to the user.
Mail app version
5.10.7 (behaviour confirmed unchanged on main at a406c97ecacd25b220386c37c29adfe5bbed0d1a, 2026-07-08)
Nextcloud version
33.0.6
Mailserver or service
Microsoft 365 (outlook.office365.com, XOAUTH2). Google path affected by inspection, not tested.
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
Found while investigating a separate defect in the OAuth reconnect flow, filed separately: updateAccount() synchronises mailboxes before getUserConsent() runs, which makes reconnection impossible once a refresh token has expired. The two are independent — this one would still be worth fixing on its own, since it hides every provider-side authorization failure.
Steps to reproduce
code— e.g. decline the consent screen, or let the app registration hit a Conditional Access policy. The provider then calls the redirect URI with?error=access_denied&error_description=…and nocode.Expected behavior
The user is told the authorization failed, and the reason is recorded in nextcloud.log.
Actual behavior
The page renders "Account connected — You can close this window", identical to a successful authorization. Nothing is written to
nextcloud.log. No token is stored.The user has no way to distinguish a successful link from a failed one, and an administrator inspecting the logs afterwards finds no trace of the failure at all.
Cause
oauthRedirect()accepts an$errorparameter and never reads it. Every outcome — success, missingcode, unauthenticated session, and provider-returned error — renders the sameoauth_donetemplate:mail/lib/Controller/MicrosoftIntegrationController.php
Lines 83 to 102 in a406c97
$erroroccurs exactly three times in the file: in the docblock, in the signature, and in the// TODO: handle errorcomment above the branch that ignores it. A provider error therefore lands in the!isset($code, $state)branch and is discarded.Of the four exit paths, only two log anything (
invalid OAuth statewarns;finishConnect()failures log). The two silent ones are the two a user is most likely to hit.GoogleIntegrationController::oauthRedirect()has the same signature and the same structure, so this affects both providers:mail/lib/Controller/GoogleIntegrationController.php
Line 79 in a406c97
Impact
This is mostly a diagnosability problem, but a sharp one. While debugging an unrelated reconnect failure on a live instance, this behaviour cost several rounds of investigation: the "Account connected" page was taken as evidence that authorization had succeeded, when in fact no token had been written. The only reliable signal was inspecting
oauth_token_ttlandoauth_access_tokeninoc_mail_accountsdirectly.Any user hitting a declined consent screen, an expired authorization code, or a tenant Conditional Access policy will be told their account is connected when it is not.
Suggested fix
In the
!isset($code, $state)branch, when$erroris present:$this->logger->warning('OAuth authorization failed', ['error' => $error, 'error_description' => $errorDescription])— noteerror_descriptionwould need adding to the signature; it carries the provider'sAADSTS…code, which is the actually diagnostic part.oauth_donetemplate rather than the success text, so the user knows to retry.Both controllers would need the change. Passing
$errorthrough to the template is enough; it does not need to be surfaced verbatim to the user.Mail app version
5.10.7 (behaviour confirmed unchanged on
mainata406c97ecacd25b220386c37c29adfe5bbed0d1a, 2026-07-08)Nextcloud version
33.0.6
Mailserver or service
Microsoft 365 (
outlook.office365.com, XOAUTH2). Google path affected by inspection, not tested.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
Found while investigating a separate defect in the OAuth reconnect flow, filed separately:
updateAccount()synchronises mailboxes beforegetUserConsent()runs, which makes reconnection impossible once a refresh token has expired. The two are independent — this one would still be worth fixing on its own, since it hides every provider-side authorization failure.