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.
182 lines
6.7 KiB
Python
182 lines
6.7 KiB
Python
#!/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()
|