feat: Discourse SSO + User API key extraction working

Discourse SSO flow: Cloudron login -> click "Log In" -> click OpenID
Connect button -> complete signup (enter username) -> logged in.

User API key generated via Discourse RSA-based flow:
1. Generate RSA keypair, submit public key
2. Authorize request on Discourse
3. Capture encrypted payload from POST response
4. Decrypt with PKCS1v15 padding (Discourse uses this, not OAEP)
5. Parse JSON to extract the key field

API key verified working: User-Api-Key header returns 30 topics from
/latest.json. Key stored in Bitwarden as "vp-techops Discourse".

Redmine SSO is blocked: Cloudron returns "You do not have access" --
the vp-techops user needs app access granted by Cloudron admin.

Also added cryptography==44.0.1 to requirements for RSA operations.
This commit is contained in:
TSYS Group COO
2026-08-13 21:25:29 -05:00
parent be2f607839
commit 2d01a9f962
5 changed files with 777 additions and 0 deletions
+293
View File
@@ -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()