Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<description><![CDATA[GitHub integration provides a dashboard widget displaying your most important notifications
and a unified search provider for repositories, issues and pull requests. It also provides a link reference provider
to render links to issues, pull requests and comments in Talk and Text.]]></description>
<version>3.2.2</version>
<version>3.2.3</version>
<licence>agpl</licence>
<author>Julien Veyssier</author>
<namespace>Github</namespace>
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
}
],
"require": {
"league/commonmark": "^2.3",
"league/commonmark": "^2.8.2",
"php": "^8.2",
"bamarni/composer-bin-plugin": "^1.8"
},
Expand Down
26 changes: 13 additions & 13 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions tests/integration/GitHubHtml.php
Original file line number Diff line number Diff line change
Expand Up @@ -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")]',
Expand Down Expand Up @@ -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 === '' ? '<empty>' : $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 === [] ? '<no forms or headings found>' : implode(' | ', $parts);
}
}
49 changes: 47 additions & 2 deletions tests/integration/GithubOauthIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -327,14 +342,40 @@ 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,
'body' => $body,
];
}

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);
Expand Down Expand Up @@ -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');
}
Expand Down
Loading