#!/usr/bin/env python3 """ diagnose-sso-chain.py -- Trace the full SSO navigation chain for one agent. Captures every navigation, page title, and visible messages during: 1. Cloudron panel login (establishes OIDC session) 2. Redmine SSO attempt (click -> ... -> final URL) 3. Discourse SSO attempt (modal -> OIDC -> signup/login -> final state) Usage: docker compose run --rm --entrypoint python3 \ -e AGENT=vp-secops provision diagnose-sso-chain.py """ import os, sys, time from pathlib import Path from playwright.sync_api import sync_playwright sys.path.insert(0, os.path.dirname(__file__)) from bw_helper import BitwardenHelper CLOUDRON_BASE = "https://my.knownelement.com" REDMINE_URL = "https://projects.knownelement.com" DISCOURSE_URL = "https://community.turnsys.com" STATE_DIR = Path("/app/state") AGENT = os.environ.get("AGENT", "vp-secops") def snapshot(page, label, trail): body = "" try: body = page.evaluate("() => document.body.innerText.substring(0, 250)") except Exception: pass entry = f"[{label}] {page.url}\n body: {' | '.join(body.splitlines()[:6])}" trail.append(entry) print(entry) def cloudron_login(page, bw): item = f"{AGENT} Cloudron" email_map = { "vp-secops": "tsgstaff-coo-vpsecops@turnsys.com", "vp-techcompliance": "tsgstaff-coo-vptechcompliance@turnsys.com", } email = email_map.get(AGENT, f"{AGENT}@turnsys.com") password = bw.get_item_password(item) page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(2000) page.wait_for_selector("#inputPassword", timeout=15000) page.click("#inputUsername") page.keyboard.type(email) page.click("#inputPassword") page.keyboard.type(password) page.locator('[role="button"]:has-text("Log in")').first.click() page.wait_for_timeout(4000) totp = page.query_selector("#inputTotpToken") if totp and totp.is_visible(): totp.click() page.keyboard.type(bw.get_totp(item)) page.locator("#totpTokenSubmitButton").click() page.wait_for_timeout(5000) def main(): bw = BitwardenHelper( client_id=os.environ["BW_CLIENTID"], client_secret=os.environ["BW_CLIENTSECRET"], password=os.environ["BW_PASSWORD"], server_url=os.environ.get("BW_SERVER", ""), ) bw.login() trail = [] with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context(viewport={"width": 1280, "height": 1024}) page = context.new_page() page.on("framenavigated", lambda frame: None) # no-op; we poll manually print(f"=== {AGENT}: Cloudron login ===") cloudron_login(page, bw) snapshot(page, "cloudron", trail) print(f"\n=== {AGENT}: Redmine SSO chain ===") page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(2000) snapshot(page, "redmine-login", trail) page.locator("#login-oauth-submit-1").first.click() for i in range(6): page.wait_for_timeout(1500) snapshot(page, f"redmine+{(i+1)*1.5}s", trail) if "projects.knownelement.com" in page.url and "/login" not in page.url: break # capture any flash error err = page.evaluate("""() => { const f = document.querySelector('#flash_notice, #flash_error, .flash, .error, .message'); return f ? f.textContent.trim() : ''; }""") print(f" flash/error element: {err!r}") print(f"\n=== {AGENT}: Discourse SSO chain ===") page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(3000) snapshot(page, "discourse-home", trail) login_btn = page.locator(".login-button") if login_btn.count() > 0: login_btn.first.click() page.wait_for_timeout(2000) snapshot(page, "discourse-modal", trail) sso = page.locator('button:has-text("OpenID")') if sso.count() > 0: sso.first.click() for i in range(6): page.wait_for_timeout(2000) snapshot(page, f"discourse+{(i+1)*2}s", trail) if "/signup" not in page.url and "/login" not in page.url: break if "/signup" in page.url: body = page.evaluate("() => document.body.innerText") print(f" SIGNUP PAGE body: {body[:300]!r}") page.screenshot(path=str(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.png"), full_page=True) browser.close() (STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.txt").write_text( "\n".join(trail)) if __name__ == "__main__": main()