Files
agent-identity-provisioning/provision-discourse.py
T
TSYS Group COO 2d01a9f962 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.
2026-08-13 21:25:29 -05:00

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()