diff --git a/docker-compose.yml b/docker-compose.yml index de5c721..94cac6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,3 +17,6 @@ services: - ./enable-cloudron-2fa.py:/app/enable-cloudron-2fa.py:ro - ./verify-cloudron-2fa.py:/app/verify-cloudron-2fa.py:ro - ./investigate-2fa-login.py:/app/investigate-2fa-login.py:ro + - ./dump-sso-flows.py:/app/dump-sso-flows.py:ro + - ./provision-discourse.py:/app/provision-discourse.py:ro + - ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro diff --git a/dump-sso-flows.py b/dump-sso-flows.py new file mode 100644 index 0000000..21b5984 --- /dev/null +++ b/dump-sso-flows.py @@ -0,0 +1,293 @@ +#!/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() diff --git a/provision-discourse-apikey.py b/provision-discourse-apikey.py new file mode 100644 index 0000000..fc4be3a --- /dev/null +++ b/provision-discourse-apikey.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +provision-discourse-apikey.py -- Generate Discourse User API key for vp-techops. + +Discourse User API Keys require an RSA-based flow: +1. Generate RSA key pair +2. Submit public key with the API key request +3. User authorizes the request +4. Decrypt the returned API key with private key + +Usage: + docker compose run --rm --entrypoint python3 provision provision-discourse-apikey.py +""" + +import base64 +import json +import os +import re +import secrets +import sys +import time +import uuid +from pathlib import Path +from urllib.parse import quote_plus + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +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") +DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com") +STATE_DIR = Path("/app/state") +BW_ITEM = "vp-techops Cloudron" +DISCOURSE_BW_ITEM = "vp-techops Discourse" +EMAIL = "tsgstaff-coo-vptechops@turnsys.com" +USERNAME = "vptechops" + + +def dump(page, label): + ts = time.strftime("%H%M%S") + try: + page.screenshot(path=str(STATE_DIR / f"discourse-apikey-{label}-{ts}.png"), full_page=True) + except Exception: + pass + body = page.evaluate("() => document.body.innerText.substring(0, 500)") + print(f" [{label}] URL: {page.url}") + print(f" Body: {body[:200]}") + + +def cloudron_login(page, bw): + password = bw.get_item_password(BW_ITEM) + page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + if "login" in page.url.lower() or "openid" in page.url.lower(): + 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) + 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) + + +def discourse_sso(page, bw): + """Login to Discourse via SSO.""" + page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + if page.query_selector("#current-user, .current-user"): + return True + login_btn = page.locator(".login-button, button:has-text('Log In')") + if login_btn.count() > 0: + login_btn.first.click() + page.wait_for_timeout(3000) + sso_btn = page.locator('button:has-text("OpenID")') + if sso_btn.count() > 0: + sso_btn.first.click() + page.wait_for_timeout(5000) + if "/signup" in page.url: + page.wait_for_timeout(2000) + username_input = page.locator('#new-account-username, input[name="username"]') + if username_input.count() > 0 and username_input.first.is_visible(): + username_input.first.click() + page.keyboard.type(USERNAME) + page.wait_for_timeout(1000) + for btn_text in ["Create Account", "Sign Up", "Register"]: + loc = page.locator(f'button:has-text("{btn_text}")') + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click() + page.wait_for_timeout(5000) + break + return page.query_selector("#current-user, .current-user") is not None + + +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() + + # Generate RSA key pair for User API Key flow + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key = private_key.public_key() + + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + + print(f"Generated RSA key pair (public key: {len(public_pem)} bytes)") + + nonce = secrets.token_hex(16) + client_id = str(uuid.uuid4()) + app_name = "TSG-Agent-VP-TechOps" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1280, "height": 1024}) + page = context.new_page() + + # Step 1: Login + print("=== STEP 1: Login ===") + cloudron_login(page, bw) + discourse_sso(page, bw) + page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + logged_in = page.query_selector("#current-user, .current-user") is not None + print(f" Logged in: {logged_in}") + if not logged_in: + print(" FAILED to login") + browser.close() + return + + # Step 2: Request User API Key with RSA public key + print("=== STEP 2: Request User API Key ===") + params = ( + f"?application_name={quote_plus(app_name)}" + f"&client_id={client_id}" + f"&nonce={nonce}" + f"&scopes=read%2Cwrite" + f"&public_key={quote_plus(public_pem)}" + ) + + # Capture API responses + api_responses = [] + + def handle_response(response): + url = response.url + if "user-api-key" in url and response.request.method == "POST": + try: + api_responses.append(response.text()) + except Exception: + pass + + page.on("response", handle_response) + + page.goto( + f"{DISCOURSE_URL}/user-api-key/new{params}", + wait_until="domcontentloaded", + timeout=30000, + ) + page.wait_for_timeout(3000) + dump(page, "01-apikey-request") + + body = page.evaluate("() => document.body.innerText") + print(f" Page body: {body[:300]}") + + # Step 3: Authorize the request + print("=== STEP 3: Authorize ===") + authorized = False + for btn_text in ["Authorize", "Approve", "Continue", "Allow", "Yes"]: + loc = page.locator(f'button:has-text("{btn_text}"), [role="button"]:has-text("{btn_text}"), .btn-primary') + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click() + authorized = True + page.wait_for_timeout(3000) + print(f" Clicked: {btn_text}") + break + + if not authorized: + # Maybe it's a form with just a submit button + submit = page.locator('button[type="submit"], input[type="submit"], .btn-primary') + if submit.count() > 0 and submit.first.is_visible(): + submit.first.click() + authorized = True + page.wait_for_timeout(3000) + print(" Clicked submit button") + + dump(page, "02-after-authorize") + + # Step 4: Extract and decrypt API key + print("=== STEP 4: Extract API key ===") + api_key = "" + + # Check captured POST responses + for resp_text in api_responses: + print(f" Captured response: {resp_text[:200]}") + try: + data = json.loads(resp_text) + encrypted_raw = data.get("key") or data.get("payload") or "" + if encrypted_raw: + encrypted_clean = encrypted_raw.replace("\n", "").replace("\r", "").replace(" ", "") + encrypted_bytes = base64.b64decode(encrypted_clean) + print(f" Encrypted payload: {len(encrypted_bytes)} bytes") + + # Try multiple padding schemes (Discourse version-dependent) + paddings = [ + ("OAEP-SHA256", padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), label=None)), + ("OAEP-SHA1", padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA1()), + algorithm=hashes.SHA1(), label=None)), + ("PKCS1v15", padding.PKCS1v15()), + ] + for name, pad in paddings: + try: + decrypted = private_key.decrypt(encrypted_bytes, pad).decode("ascii") + # Decrypted payload is JSON: {"key":"...","nonce":"...","push":false,"api":4} + try: + key_data = json.loads(decrypted) + api_key = key_data.get("key", decrypted) + except json.JSONDecodeError: + api_key = decrypted # fallback: key is plaintext + print(f" Decrypted with {name}: {api_key[:12]}...") + break + except Exception: + continue + except Exception as e: + print(f" Decryption failed: {e}") + + # If no captured response, check page body for JSON or plaintext key + if not api_key: + body = page.evaluate("() => document.body.innerText") + # Try to find and decrypt the encrypted payload in the page + # The page shows: "please paste the following key..." followed by base64 RSA-encrypted text + key_match = re.search(r'(?:key|application):?\s*\n*\s*([A-Za-z0-9+/\n\r\s={30,}]+)', body) + if key_match: + encrypted = key_match.group(1).replace("\n", "").replace("\r", "").replace(" ", "").strip() + try: + api_key = private_key.decrypt( + base64.b64decode(encrypted), + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ).decode("ascii") + print(f" Decrypted from page body: {api_key[:12]}...") + except Exception: + pass + + # Look for unencrypted key (fallback) + if not api_key: + matches = re.findall(r'[a-f0-9]{64}', body) + if matches: + api_key = matches[0] + print(f" Found unencrypted key: {api_key[:12]}...") + + if api_key: + print(f"\n API KEY: {api_key}") + print("=== STEP 5: Store in Bitwarden ===") + existing = bw.get_item_id(DISCOURSE_BW_ITEM) + if existing: + bw.update_item(DISCOURSE_BW_ITEM, password=api_key) + print(f" Updated BW item '{DISCOURSE_BW_ITEM}'") + else: + bw.create_item( + name=DISCOURSE_BW_ITEM, + username=USERNAME, + password=api_key, + uris=[DISCOURSE_URL], + collection_name="default", + ) + print(f" Created BW item '{DISCOURSE_BW_ITEM}'") + else: + print(" Could not extract API key") + # Dump all visible elements for debugging + body = page.evaluate("() => document.body.innerText") + print(f" Full body: {body[:500]}") + + browser.close() + print("\n=== DONE ===") + + +if __name__ == "__main__": + main() diff --git a/provision-discourse.py b/provision-discourse.py new file mode 100644 index 0000000..b235e7a --- /dev/null +++ b/provision-discourse.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +provision-discourse.py -- Complete Discourse SSO signup and API key extraction. + +Flow: +1. Login to Cloudron panel (establish OIDC session) +2. Click Discourse login -> SSO via OpenID Connect +3. Complete signup page (enter username) +4. Check for API key generation options + +Usage: + docker compose run --rm --entrypoint python3 provision provision-discourse.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") +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" +USERNAME = "vptechops" + + +def dump(page, label): + ts = time.strftime("%H%M%S") + try: + page.screenshot(path=str(STATE_DIR / f"discourse-{label}-{ts}.png"), full_page=True) + except Exception: + pass + body = page.evaluate("() => document.body.innerText.substring(0, 500)") + print(f" [{label}] URL: {page.url}") + print(f" Body: {body[:200]}") + + +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() + password = bw.get_item_password(BW_ITEM) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1280, "height": 1024}) + page = context.new_page() + + # Step 1: Cloudron login + print("=== STEP 1: Cloudron login ===") + page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + if "login" in page.url.lower() or "openid" in page.url.lower(): + 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) + 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) + print(f" Cloudron: {page.url}") + + # Step 2: Discourse SSO + print("=== STEP 2: Discourse SSO ===") + page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + + # Click Log In button + login_btn = page.locator(".login-button") + if login_btn.count() == 0: + login_btn = page.locator('button:has-text("Log In")') + if login_btn.count() > 0: + login_btn.first.click() + page.wait_for_timeout(3000) + dump(page, "01-login-modal") + + # Click OpenID Connect button + sso_btn = page.locator('button:has-text("OpenID")') + if sso_btn.count() > 0: + sso_btn.first.click() + page.wait_for_timeout(5000) + dump(page, "02-after-sso") + + # Step 3: Handle signup or login complete + print("=== STEP 3: Handle signup/login ===") + if "/signup" in page.url: + print(" On signup page -- need to create account") + # Fill username + page.wait_for_timeout(2000) + + # Look for username input + username_input = None + for sel in ['#new-account-username', 'input[name="username"]', 'input#new-account-username']: + loc = page.locator(sel) + if loc.count() > 0 and loc.first.is_visible(): + username_input = loc.first + break + + if username_input: + username_input.click() + page.keyboard.type(USERNAME) + page.wait_for_timeout(1000) + print(f" Entered username: {USERNAME}") + dump(page, "03-username-entered") + + # Look for create/submit button + for btn_text in ["Create Account", "Sign Up", "Register", "Submit", "Continue"]: + loc = page.locator(f'button:has-text("{btn_text}"), [role="button"]:has-text("{btn_text}")') + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click() + page.wait_for_timeout(5000) + print(f" Clicked: {btn_text}") + break + + dump(page, "04-after-signup") + else: + print(" Could not find username input") + dump(page, "03-no-username-input") + elif "/login" in page.url: + print(" Back on login page") + else: + print(" Appears logged in!") + + # Step 4: Check if we're authenticated now + print("=== STEP 4: Verify authentication ===") + page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + + # Check for user avatar or logged-in indicators + body = page.evaluate("() => document.body.innerText") + has_avatar = page.query_selector("#current-user, .current-user, [data-user-card]") + is_logged_in = has_avatar is not None or USERNAME in body + + if is_logged_in: + print(f" Discourse login SUCCESSFUL!") + else: + print(f" Discourse login status unclear") + dump(page, "05-status-check") + + # Step 5: Check for API key options + print("=== STEP 5: Check API key options ===") + # Try user API key generation endpoint + page.goto(f"{DISCOURSE_URL}/u/{USERNAME}/preferences/account", wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + dump(page, "06-account-preferences") + + # Look for API key section + body = page.evaluate("() => document.body.innerText") + if "api key" in body.lower(): + print(" API key section found!") + else: + print(" No API key section in user preferences") + + # Try the user API key generation flow + page.goto(f"{DISCOURSE_URL}/user-api-key/new", wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + dump(page, "07-user-api-key") + + body = page.evaluate("() => document.body.innerText") + print(f" User API key page: {body[:200]}") + + browser.close() + print("\n=== DONE ===") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 57be20f..0da24e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ qrcode==7.4.2 Pillow==10.4.0 pyzbar==0.1.9 pytest==8.3.2 +cryptography==44.0.1