fix: resolve OIDC consent flow 500 errors on NC 32

Root cause: ConsentController::grant() only passed client_id and scope
in the post-consent redirect, relying on PHP session fallback for state,
response_type, redirect_uri etc. On NC 32 (PHP 8.4), session values
were intermittently lost between session->close() and the subsequent GET
request, causing 500 errors from trim(null) / matchRedirectUri(null).

OIDC app fixes:
- Pass all OAuth params in consent redirect URL (eliminates session race)
- Add null safety guard in authorize endpoint (400 instead of 500)

Test infra fixes:
- Wait for OIDC redirect chain to settle before handling consent screen
  (fixes "Execution context was destroyed" Playwright errors)
- Capture nextcloud.log in CI failure artifacts for PHP error debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-18 16:47:20 +01:00
co-authored by Claude Opus 4.6
parent 945b01cbf5
commit ad4ceaff30
3 changed files with 81 additions and 29 deletions
+4 -1
View File
@@ -206,7 +206,9 @@ jobs:
- name: Collect service logs on failure - name: Collect service logs on failure
if: failure() if: failure()
run: docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1 run: |
docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1
docker compose exec -T app cat /var/www/html/data/nextcloud.log 2>/dev/null | tail -100 > /tmp/nextcloud-app.log 2>&1 || true
- name: Upload debug artifacts - name: Upload debug artifacts
if: failure() if: failure()
@@ -216,5 +218,6 @@ jobs:
path: | path: |
/tmp/*.png /tmp/*.png
/tmp/docker-compose-logs.txt /tmp/docker-compose-logs.txt
/tmp/nextcloud-app.log
retention-days: 7 retention-days: 7
if-no-files-found: ignore if-no-files-found: ignore
+76 -27
View File
@@ -382,27 +382,34 @@ async def nc_mcp_oauth_client_with_elicitation(
await page.wait_for_load_state("networkidle", timeout=60000) await page.wait_for_load_state("networkidle", timeout=60000)
logger.info(" ✓ Login completed") logger.info(" ✓ Login completed")
# Wait for the OIDC redirect chain to settle before handling consent.
logger.info(" Waiting for OIDC redirect chain to settle...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "/callback" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen if present # Handle consent screen if present
try: if "/consent" in page.url:
logger.info(f" Current URL before consent: {page.url}") await page.wait_for_load_state("networkidle", timeout=10000)
consent_handled = await _handle_oauth_consent_screen(page, username) try:
if consent_handled: logger.info(f" Current URL before consent: {page.url}")
logger.info(" ✓ Consent granted") consent_handled = await _handle_oauth_consent_screen(page, username)
else: if consent_handled:
logger.warning(" ⚠ No consent screen detected") logger.info(" ✓ Consent granted")
# Take screenshot for debugging else:
screenshot_path = f"/tmp/elicitation_no_consent_{uuid.uuid4()}.png" logger.warning(" ⚠ Consent handler returned False")
except Exception as e:
logger.warning(f" ⚠ Consent screen handling failed: {e}")
screenshot_path = (
f"/tmp/elicitation_consent_error_{uuid.uuid4()}.png"
)
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
logger.info(f" Screenshot saved: {screenshot_path}") logger.info(f" Screenshot saved: {screenshot_path}")
# Log page title for debugging else:
page_title = await page.title() logger.debug(f" No consent screen (URL: {page.url})")
logger.info(f" Page title: {page_title}")
except Exception as e:
logger.warning(f" ⚠ Consent screen handling failed: {e}")
# Take screenshot for debugging
screenshot_path = f"/tmp/elicitation_consent_error_{uuid.uuid4()}.png"
await page.screenshot(path=screenshot_path)
logger.info(f" Screenshot saved: {screenshot_path}")
# Wait for OAuth callback URL to be reached # Wait for OAuth callback URL to be reached
# The MCP server's callback endpoint will handle token exchange # The MCP server's callback endpoint will handle token exchange
@@ -1843,11 +1850,24 @@ async def playwright_oauth_token(
current_url = page.url current_url = page.url
logger.info(f"After login, current URL: {current_url}") logger.info(f"After login, current URL: {current_url}")
# Wait for the OIDC redirect chain to settle before handling consent.
# After login, the flow goes: /apps/oidc/redirect (JS page) → JS navigates
# to /authorize → 303 to /consent. networkidle fires after the JS page
# loads but before the JS navigation starts.
logger.info("Waiting for OIDC redirect chain to settle...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen if present # Handle consent screen if present
try: if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
await _handle_oauth_consent_screen(page, username) await _handle_oauth_consent_screen(page, username)
except Exception as e: else:
logger.debug(f"No consent screen or already authorized: {e}") logger.debug(f"No consent screen (URL: {page.url})")
# Wait for callback server to receive the auth code # Wait for callback server to receive the auth code
# Browser will be redirected to localhost:8081 which will capture the code # Browser will be redirected to localhost:8081 which will capture the code
@@ -2138,11 +2158,21 @@ async def _get_oauth_token_with_scopes(
current_url = page.url current_url = page.url
logger.info(f"After login, current URL: {current_url}") logger.info(f"After login, current URL: {current_url}")
# Wait for the OIDC redirect chain to settle before handling consent.
logger.info(f"Waiting for OIDC redirect chain to settle for {username}...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen if present # Handle consent screen if present
try: if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
await _handle_oauth_consent_screen(page, username) await _handle_oauth_consent_screen(page, username)
except Exception as e: else:
logger.debug(f"No consent screen or already authorized: {e}") logger.debug(f"No consent screen for {username} (URL: {page.url})")
# Wait for callback server to receive the auth code # Wait for callback server to receive the auth code
logger.info(f"Waiting for auth code with state: {state[:16]}...") logger.info(f"Waiting for auth code with state: {state[:16]}...")
@@ -2510,11 +2540,30 @@ async def _get_oauth_token_for_user(
await page.wait_for_load_state("networkidle", timeout=30000) await page.wait_for_load_state("networkidle", timeout=30000)
current_url = page.url current_url = page.url
# Wait for the OIDC redirect chain to settle before handling consent.
# After login, the flow goes: /apps/oidc/redirect (JS page) → JS navigates
# to /authorize → 303 to /consent. networkidle fires after the JS page
# loads but before the JS navigation starts, so we must wait for the URL
# to reach either the consent page or the callback.
logger.info(f"Waiting for OIDC redirect chain to settle for {username}...")
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
else:
logger.warning(
f"OIDC redirect chain did not settle for {username}, "
f"current URL: {page.url}"
)
# Handle consent screen if present # Handle consent screen if present
try: if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
await _handle_oauth_consent_screen(page, username) await _handle_oauth_consent_screen(page, username)
except Exception as e: else:
logger.debug(f"No consent screen or already authorized for {username}: {e}") logger.debug(f"No consent screen for {username} (URL: {page.url})")
# Wait for callback server to receive the auth code # Wait for callback server to receive the auth code
# Browser will be redirected to localhost:8081 which will capture the code # Browser will be redirected to localhost:8081 which will capture the code