App credentials now flow for every agent: shared handle_oidc_interaction
helper fills the per-app OIDC login form/TOTP/consent (the panel session
is not shared across app clients -- each may demand fresh credentials),
and the Discourse signup clears the prefilled username field before
typing (prefill+typed concatenation exceeded the 20-char cap and failed
validation silently).
BW helper hardened against the sync races observed across concurrent
containers: get_item_id re-syncs stale caches, create/edit retry with
backoff and post-write sync. This class of failure was mine -- the
login-path fix from session 3 left read paths on stale caches.
Final validated matrix (validate-all-logins.py, fresh-context logins
plus live API checks): 9/10 fully green; vp-compliance blocked on a
corrupt stored password (Cloudron admin reset needed). Redmine access
still Cloudron-denied for vp-secops, svp-knel, vp-techcompliance,
vp-facilities ("You do not have access" at the OIDC interaction).
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
diagnose-sso-chain.py -- Trace the full SSO navigation chain for one agent.
|
|
|
|
Captures every navigation, page title, and visible messages during:
|
|
1. Cloudron panel login (establishes OIDC session)
|
|
2. Redmine SSO attempt (click -> ... -> final URL)
|
|
3. Discourse SSO attempt (modal -> OIDC -> signup/login -> final state)
|
|
|
|
Usage:
|
|
docker compose run --rm --entrypoint python3 \
|
|
-e AGENT=vp-secops provision diagnose-sso-chain.py
|
|
"""
|
|
|
|
import os, sys, time
|
|
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 = "https://my.knownelement.com"
|
|
REDMINE_URL = "https://projects.knownelement.com"
|
|
DISCOURSE_URL = "https://community.turnsys.com"
|
|
STATE_DIR = Path("/app/state")
|
|
|
|
AGENT = os.environ.get("AGENT", "vp-secops")
|
|
|
|
|
|
def snapshot(page, label, trail):
|
|
body = ""
|
|
try:
|
|
body = page.evaluate("() => document.body.innerText.substring(0, 250)")
|
|
except Exception:
|
|
pass
|
|
entry = f"[{label}] {page.url}\n body: {' | '.join(body.splitlines()[:6])}"
|
|
trail.append(entry)
|
|
print(entry)
|
|
|
|
|
|
def cloudron_login(page, bw):
|
|
item = f"{AGENT} Cloudron"
|
|
email_map = {
|
|
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
|
|
"vp-techcompliance": "tsgstaff-coo-vptechcompliance@turnsys.com",
|
|
}
|
|
email = email_map.get(AGENT, f"{AGENT}@turnsys.com")
|
|
password = bw.get_item_password(item)
|
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(2000)
|
|
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(4000)
|
|
totp = page.query_selector("#inputTotpToken")
|
|
if totp and totp.is_visible():
|
|
totp.click()
|
|
page.keyboard.type(bw.get_totp(item))
|
|
page.locator("#totpTokenSubmitButton").click()
|
|
page.wait_for_timeout(5000)
|
|
|
|
|
|
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()
|
|
|
|
trail = []
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
context = browser.new_context(viewport={"width": 1280, "height": 1024})
|
|
page = context.new_page()
|
|
|
|
page.on("framenavigated", lambda frame: None) # no-op; we poll manually
|
|
|
|
print(f"=== {AGENT}: Cloudron login ===")
|
|
cloudron_login(page, bw)
|
|
snapshot(page, "cloudron", trail)
|
|
|
|
print(f"\n=== {AGENT}: Redmine SSO chain ===")
|
|
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(2000)
|
|
snapshot(page, "redmine-login", trail)
|
|
page.locator("#login-oauth-submit-1").first.click()
|
|
for i in range(6):
|
|
page.wait_for_timeout(1500)
|
|
snapshot(page, f"redmine+{(i+1)*1.5}s", trail)
|
|
if "projects.knownelement.com" in page.url and "/login" not in page.url:
|
|
break
|
|
# capture any flash error
|
|
err = page.evaluate("""() => {
|
|
const f = document.querySelector('#flash_notice, #flash_error, .flash, .error, .message');
|
|
return f ? f.textContent.trim() : '';
|
|
}""")
|
|
print(f" flash/error element: {err!r}")
|
|
|
|
print(f"\n=== {AGENT}: Discourse SSO chain ===")
|
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
snapshot(page, "discourse-home", trail)
|
|
login_btn = page.locator(".login-button")
|
|
if login_btn.count() > 0:
|
|
login_btn.first.click()
|
|
page.wait_for_timeout(2000)
|
|
snapshot(page, "discourse-modal", trail)
|
|
sso = page.locator('button:has-text("OpenID")')
|
|
if sso.count() > 0:
|
|
sso.first.click()
|
|
for i in range(6):
|
|
page.wait_for_timeout(2000)
|
|
snapshot(page, f"discourse+{(i+1)*2}s", trail)
|
|
if "/signup" not in page.url and "/login" not in page.url:
|
|
break
|
|
if "/signup" in page.url:
|
|
body = page.evaluate("() => document.body.innerText")
|
|
print(f" SIGNUP PAGE body: {body[:300]!r}")
|
|
|
|
page.screenshot(path=str(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.png"),
|
|
full_page=True)
|
|
browser.close()
|
|
|
|
(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.txt").write_text(
|
|
"\n".join(trail))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|