diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 6981a61..4912f27 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -37,7 +37,7 @@ jobs:
matrix:
php-versions: ['8.2']
databases: ['sqlite']
- server-versions: ['master']
+ server-versions: ['master', 'stable33']
name: php${{ matrix.php-versions }}-${{ matrix.databases }}-${{ matrix.server-versions }}
@@ -80,3 +80,11 @@ jobs:
CI_USER_PASSWORD: ${{ secrets.CI_USER_PASSWORD }}
CI_TOTP_SECRET: ${{ secrets.CI_TOTP_SECRET }}
run: composer run test:integration
+
+ - name: Upload Nextcloud log on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: nextcloud-log-${{ matrix.server-versions }}
+ path: data/nextcloud.log
+ if-no-files-found: ignore
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10ea4cd..00400f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+## 3.2.3 - 2026-04-21
+
+### Added
+
+- Added support for Nextcloud 34.
+
+### Changed
+
+- Updated dependencies & translations.
+
## 3.2.2 - 2025-11-10
### Added
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 5def02a..0ea0b13 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -10,7 +10,7 @@
- 3.2.2
+ 3.2.3
agpl
Julien Veyssier
Github
diff --git a/composer.json b/composer.json
index 6692413..26975c2 100644
--- a/composer.json
+++ b/composer.json
@@ -7,7 +7,7 @@
}
],
"require": {
- "league/commonmark": "^2.3",
+ "league/commonmark": "^2.8.2",
"php": "^8.2",
"bamarni/composer-bin-plugin": "^1.8"
},
diff --git a/composer.lock b/composer.lock
index 61f7458..e1af0a6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "80537f3ad881fb421b9530dab60ddcac",
+ "content-hash": "c7083f1461bd67ef44ce5c9f3c3baecf",
"packages": [
{
"name": "bamarni/composer-bin-plugin",
@@ -140,16 +140,16 @@
},
{
"name": "league/commonmark",
- "version": "2.8.1",
+ "version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "84b1ca48347efdbe775426f108622a42735a6579"
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579",
- "reference": "84b1ca48347efdbe775426f108622a42735a6579",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"shasum": ""
},
"require": {
@@ -243,7 +243,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-05T21:37:03+00:00"
+ "time": "2026-03-19T13:16:38+00:00"
},
{
"name": "league/config",
@@ -604,16 +604,16 @@
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.33.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
@@ -664,7 +664,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.36.0"
},
"funding": [
{
@@ -684,7 +684,7 @@
"type": "tidelift"
}
],
- "time": "2025-01-02T08:10:11+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
}
],
"packages-dev": [
@@ -905,5 +905,5 @@
"platform-overrides": {
"php": "8.2"
},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/tests/integration/GitHubHtml.php b/tests/integration/GitHubHtml.php
index c964f56..b046d18 100644
--- a/tests/integration/GitHubHtml.php
+++ b/tests/integration/GitHubHtml.php
@@ -57,6 +57,13 @@ public static function findTwoFactorForm(DOMXPath $selector): ?DOMElement {
]);
}
+ public static function findTwoFactorCheckupDelayForm(DOMXPath $selector): ?DOMElement {
+ return self::findForm($selector, [
+ '//form[@action="/settings/two_factor_checkup/delay"]',
+ '//form[contains(@action, "two_factor_checkup/delay")]',
+ ]);
+ }
+
public static function findTotpAlternativeUrl(DOMXPath $selector): ?string {
$linkSelectors = [
'//a[contains(@href, "two-factor/app")]',
@@ -106,4 +113,45 @@ public static function extractFormInputs(DOMXPath $selector, DOMElement $form):
return $formParams;
}
+
+ /**
+ * Summarize the forms and headings on a page for failure diagnostics.
+ * Emits form actions and input names (never values) plus h1/h2 text,
+ * so CI logs show what GitHub actually returned without leaking tokens.
+ */
+ public static function describePage(DOMXPath $selector): string {
+ $parts = [];
+
+ $forms = $selector->query('//form');
+ if ($forms !== false) {
+ foreach ($forms as $form) {
+ if (!$form instanceof DOMElement) {
+ continue;
+ }
+ $action = $form->getAttribute('action');
+ $inputNodes = $selector->query('.//input[@name] | .//button[@name]', $form);
+ $names = [];
+ if ($inputNodes !== false) {
+ foreach ($inputNodes as $input) {
+ if ($input instanceof DOMElement) {
+ $names[] = $input->getAttribute('name');
+ }
+ }
+ }
+ $parts[] = 'form(action=' . ($action === '' ? '' : $action) . ', inputs=[' . implode(',', $names) . '])';
+ }
+ }
+
+ $headings = $selector->query('//h1 | //h2');
+ if ($headings !== false) {
+ foreach ($headings as $heading) {
+ $text = trim($heading->textContent);
+ if ($text !== '') {
+ $parts[] = $heading->nodeName . '=' . mb_substr($text, 0, 120);
+ }
+ }
+ }
+
+ return $parts === [] ? '' : implode(' | ', $parts);
+ }
}
diff --git a/tests/integration/GithubOauthIntegrationTest.php b/tests/integration/GithubOauthIntegrationTest.php
index ba770fb..fdf55f6 100644
--- a/tests/integration/GithubOauthIntegrationTest.php
+++ b/tests/integration/GithubOauthIntegrationTest.php
@@ -238,6 +238,17 @@ private function interpretAuthenticatedResponse(string $body, string $finalUrl,
['selector' => $selector, 'title' => $title] = $this->getPageContext($body);
+ // GitHub periodically interrupts authenticated navigation with a "Verify your
+ // two-factor authentication (2FA) settings" checkup page. It is not a real 2FA
+ // challenge, just a reminder; POSTing the delay form dismisses it.
+ if (GitHubHtml::findTwoFactorCheckupDelayForm($selector) !== null) {
+ return [
+ 'status' => 'two_factor_checkup',
+ 'checkup_url' => $finalUrl,
+ 'body' => $body,
+ ];
+ }
+
$isTwoFactorPage = GitHubHtml::findTwoFactorForm($selector) !== null
|| str_contains($finalUrl, 'two-factor')
|| str_contains($title, 'Two-factor authentication');
@@ -261,7 +272,11 @@ private function interpretAuthenticatedResponse(string $body, string $finalUrl,
$this->fail('GitHub returned the sign-in page after the ' . $step . ' step. This usually means the authenticated session was not established or cookies were not kept. Final URL: ' . $finalUrl);
}
- $this->fail('GitHub completed the ' . $step . ' step but neither a 2FA form, an authorize form, nor a callback redirect with code was found. Final URL: ' . $finalUrl . '. Page title: ' . $title);
+ $this->fail(
+ 'GitHub completed the ' . $step . ' step but neither a 2FA form, an authorize form, nor a callback redirect with code was found. '
+ . 'Final URL: ' . $finalUrl . '. Page title: ' . $title . '. '
+ . 'Page: ' . GitHubHtml::describePage($selector)
+ );
}
private function loginToGitHub(string $authorizeUrl): array {
@@ -327,7 +342,12 @@ private function navigateToTotpPage(string $currentUrl, string $currentBody): ar
$body = $response->getBody()->getContents();
$statusCode = $response->getStatusCode();
- $this->assertOkStatus($statusCode, 'Failed to navigate to TOTP page from WebAuthn page. URL: ' . $totpUrl . '.');
+ $this->assertOkStatus(
+ $statusCode,
+ 'Failed to navigate to TOTP page from page without a recognized 2FA form. '
+ . 'Source URL: ' . $currentUrl . '. Attempted TOTP URL: ' . $totpUrl . '. '
+ . 'Source page: ' . GitHubHtml::describePage($selector) . '.'
+ );
return [
'url' => $totpUrl,
@@ -335,6 +355,27 @@ private function navigateToTotpPage(string $currentUrl, string $currentBody): ar
];
}
+ private function dismissTwoFactorCheckup(string $body, string $checkupUrl): array {
+ ['selector' => $selector] = $this->getPageContext($body);
+ $delayForm = GitHubHtml::findTwoFactorCheckupDelayForm($selector);
+ if ($delayForm === null) {
+ $this->fail('Expected a 2FA checkup delay form on ' . $checkupUrl . ' but none was found. Page: ' . GitHubHtml::describePage($selector));
+ }
+
+ $formParams = GitHubHtml::extractFormInputs($selector, $delayForm);
+ $actionUrl = GitHubHtml::resolveUrl($delayForm->getAttribute('action'), $checkupUrl);
+
+ $result = $this->requestFollowingGitHubRedirects('POST', $actionUrl, [
+ RequestOptions::FORM_PARAMS => $formParams,
+ ]);
+ $statusCode = $result['response']->getStatusCode();
+ if (($result['stopped_before_external_redirect'] ?? false) !== true && $statusCode >= 400) {
+ $this->fail('Dismissing the 2FA checkup via ' . $actionUrl . ' returned status ' . $statusCode . '.');
+ }
+
+ return $this->interpretAuthenticatedResponse($result['body'], $result['final_url'], 'checkup dismissal');
+ }
+
private function handleTwoFactorPage(string $twoFactorUrl, string $body): array {
try {
$totpCodes = Totp::generateCandidates($this->githubTotpSecret);
@@ -461,6 +502,10 @@ public function testOAuthLogin(): array {
$loginResult = $this->handleTwoFactorPage($loginResult['two_factor_url'] ?? $authorizeUrl, $loginResult['body']);
}
+ if ($loginResult['status'] === 'two_factor_checkup') {
+ $loginResult = $this->dismissTwoFactorCheckup($loginResult['body'], $loginResult['checkup_url'] ?? $authorizeUrl);
+ }
+
if ($loginResult['status'] === 'invalid_credentials') {
$this->fail('Invalid GitHub credentials');
}