#!/usr/bin/env python3 """ dump-sso-flows.py -- Comprehensive DOM dump and SSO attempt for Redmine + Discourse. Establishes Cloudron OIDC session first, then navigates to each app's login page, dumps the DOM, and attempts the SSO flow. Usage: docker compose run --rm --entrypoint python3 provision dump-sso-flows.py """ import os, sys, time, re 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 = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com") GITEA_URL = os.environ.get("GITEA_URL", "https://git.knownelement.com") REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com") DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com") STATE_DIR = Path("/app/state") BW_ITEM = "vp-techops Cloudron" EMAIL = "tsgstaff-coo-vptechops@turnsys.com" def dump(page, label): """Save screenshot + comprehensive element dump.""" ts = time.strftime("%H%M%S") try: page.screenshot(path=str(STATE_DIR / f"sso-{label}-{ts}.png"), full_page=True) except Exception: pass elements = page.evaluate("""() => { const results = []; const selector = 'input, button, select, textarea, label, a, [role="button"], ' + 'h1, h2, h3, h4, code, pre, form, [class*="oauth"], [class*="login"], ' + '[class*="btn"], [id*="login"], [id*="oauth"], [id*="sso"]'; document.querySelectorAll(selector).forEach(el => { const tag = el.tagName.toLowerCase(); const text = (el.textContent || '').trim().substring(0, 80); const role = el.getAttribute('role') || ''; const id = el.id || ''; const name = el.getAttribute('name') || ''; const type = el.getAttribute('type') || ''; const value = el.getAttribute('value') || ''; const href = (el.getAttribute('href') || '').substring(0, 50); const cls = (el.getAttribute('class') || '').substring(0, 60); const action = el.getAttribute('action') || ''; const visible = el.offsetParent !== null; if (text || id || name || type || value || href || role || action || cls.includes('btn') || cls.includes('oauth') || cls.includes('login')) { let parts = '<' + tag + '> '; if (role) parts += 'role=' + role + ' '; if (id) parts += 'id=' + id + ' '; if (type) parts += 'type=' + type + ' '; if (name) parts += 'name=' + name + ' '; if (href) parts += 'href=' + href + ' '; if (cls) parts += 'class=' + cls + ' '; parts += 'vis=' + visible + ' text="' + text + '"'; results.push(parts); } }); return results; }""") body = page.evaluate("() => document.body.innerText.substring(0, 300)") text_path = STATE_DIR / f"sso-{label}-{ts}.txt" text_path.write_text(f"URL: {page.url}\n\nBody: {body}\n\nElements:\n" + "\n".join(elements)) print(f" [{label}] {len(elements)} elements -> {text_path.name}") def cloudron_login(page, bw): """Login to Cloudron panel with TOTP support.""" password = bw.get_item_password(BW_ITEM) page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000) page.wait_for_timeout(2000) # Already logged in? if "login" not in page.url.lower() and "openid" not in page.url.lower(): print(f" Cloudron session already active ({page.url})") return True 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(3000) # Handle TOTP totp = page.query_selector("#inputTotpToken") if totp and totp.is_visible(): code = bw.get_totp(BW_ITEM) totp.click() page.keyboard.type(code) page.locator("#totpTokenSubmitButton").click() page.wait_for_timeout(5000) # Handle consent page for consent in ["Continue", "Authorize", "Allow", "Accept"]: loc = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")') if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) break logged_in = "login" not in page.url.lower() and "openid" not in page.url.lower() print(f" Cloudron login: {'SUCCESS' if logged_in else 'CHECKING...'} ({page.url})") return logged_in def try_redmine_sso(page, bw): """Attempt Redmine SSO login.""" print("\n=== REDMINE SSO ===") page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(3000) dump(page, "redmine-login-initial") # Look for SSO/OAuth button print(" Looking for SSO button...") sso_selectors = [ '#login-oauth-submit-1', 'button:has-text("KNEL")', 'button:has-text("Cloud")', 'button:has-text("Continue")', 'a:has-text("KNEL")', 'a:has-text("Cloud")', '[class*="oauth"]', 'input[name="oauth2"]', ] for sel in sso_selectors: loc = page.locator(sel) if loc.count() > 0 and loc.first.is_visible(): print(f" Found SSO button: {sel}") loc.first.click() page.wait_for_timeout(5000) dump(page, "redmine-after-sso-click") # Check if we hit OIDC consent page if "openid" in page.url.lower(): print(f" OIDC page: {page.url}") for consent in ["Continue", "Authorize", "Allow", "Accept"]: cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")') if cbtn.count() > 0 and cbtn.first.is_visible(): cbtn.first.click() page.wait_for_timeout(5000) break dump(page, "redmine-after-consent") # Check if logged in current_url = page.url body = page.evaluate("() => document.body.innerText.substring(0, 200)") if "/login" not in current_url: print(f" Redmine SSO result: LOGGED IN ({current_url})") return True else: print(f" Redmine SSO result: still on login page") print(f" Body: {body[:100]}") return False print(" No SSO button found") return False def try_discourse_sso(page, bw): """Attempt Discourse SSO login.""" print("\n=== DISCOURSE SSO ===") page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(3000) dump(page, "discourse-initial") # Click "Log In" button to open modal print(" Looking for Log In button...") login_clicked = False for sel in [ '.login-button', '.header-buttons .login-button', 'button:has-text("Log In")', '[role="button"]:has-text("Log In")', '.btn:has-text("Log In")', 'a:has-text("Log In")', ]: loc = page.locator(sel) if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) login_clicked = True print(f" Clicked login button: {sel}") break if not login_clicked: print(" Could not find Log In button") dump(page, "discourse-no-login-btn") return False dump(page, "discourse-login-modal") # Look for SSO/OIDC button inside modal print(" Looking for SSO button in modal...") sso_selectors = [ 'button:has-text("OpenID")', 'button:has-text("Connect")', 'button:has-text("Cloud")', 'button:has-text("KNEL")', '[class*="oauth"]', '[class*="openid"]', '[class*="sso"]', 'a:has-text("OpenID")', 'a:has-text("Connect")', 'button[class*="social"]', '.login-buttons button', '.auth-buttons button', '[data-login-method]', ] for sel in sso_selectors: loc = page.locator(sel) if loc.count() > 0 and loc.first.is_visible(): print(f" Found SSO button: {sel} ({loc.count()} matches)") loc.first.click() page.wait_for_timeout(5000) dump(page, "discourse-after-sso-click") # Handle OIDC consent if "openid" in page.url.lower(): for consent in ["Continue", "Authorize", "Allow", "Accept"]: cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")') if cbtn.count() > 0 and cbtn.first.is_visible(): cbtn.first.click() page.wait_for_timeout(5000) break dump(page, "discourse-after-consent") body = page.evaluate("() => document.body.innerText.substring(0, 200)") print(f" Discourse SSO result URL: {page.url}") print(f" Body: {body[:100]}") return True print(" No SSO button found in modal") # Dump ALL buttons in the modal for debugging buttons = page.evaluate("""() => { return Array.from(document.querySelectorAll('button, [role="button"], a.btn')).map(el => ({ tag: el.tagName, text: (el.textContent || '').trim().substring(0, 50), cls: (el.getAttribute('class') || '').substring(0, 60), visible: el.offsetParent !== null, })); }""") print(f" All buttons: {buttons}") return False 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() with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context(viewport={"width": 1280, "height": 1024}) page = context.new_page() print("=== Establishing Cloudron OIDC session ===") cloudron_login(page, bw) # Try Redmine SSO redmine_ok = try_redmine_sso(page, bw) # Re-establish Cloudron session for Discourse (may have been consumed) print("\n=== Re-establishing Cloudron session ===") cloudron_login(page, bw) # Try Discourse SSO discourse_ok = try_discourse_sso(page, bw) print(f"\n=== RESULTS ===") print(f" Redmine SSO: {'SUCCESS' if redmine_ok else 'FAILED'}") print(f" Discourse SSO: {'SUCCESS' if discourse_ok else 'FAILED'}") browser.close() if __name__ == "__main__": main()