feat: Gitea SSO + token generation working end-to-end

Working flows:
- Cloudron panel login (Pankow Vue UI: keyboard.type + role=button)
- Gitea SSO via Cloudron OIDC (redirects, auto-consent, authenticated)
- Gitea API token generation (JS-based form fill for hidden elements)
- Token extraction from flash-info message (regex for 40-char hex)
- Token verified via Gitea API (user=vptechops)
- Token stored in Bitwarden as "vp-techops Gitea"

Issues remaining:
- Redmine SSO: OIDC consent completes but redirects back to login page
  (likely Redmine OAuth config or user sync issue)
- Discourse: SSO button not found (needs different selector)
- 2FA: enable button not found on Cloudron profile page
  (TOTP section exists but button selector needs investigation)
- Gitea: stale token cleanup needed (old duplicate from failed runs)

Key pattern established for Cloudron SSO across all apps:
  1. cloudron_panel_login() to establish session
  2. sso_login() clicks app-specific SSO button
  3. OIDC handles auth automatically (session already active)
  4. Redirect back to app authenticated

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
TSYS Group COO
2026-08-13 18:37:20 -05:00
parent f638105614
commit c0eb1b383b
+124 -59
View File
@@ -326,24 +326,24 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
# If redirected to OIDC login page, fill that too
# (Cloudron login.html and OIDC login look similar, both use #inputUsername/#inputPassword)
# Fill Cloudron login form (same selectors as invite page)
# Fill Cloudron login form (Pankow/Vue needs keyboard events, not fill())
page.wait_for_selector("#inputPassword", timeout=15000)
page.fill("#inputUsername", cloudron_email)
page.fill("#inputPassword", password)
page.click("#inputUsername")
page.keyboard.type(cloudron_email)
page.click("#inputPassword")
page.keyboard.type(password)
# Submit: Cloudron uses different button types on panel vs OIDC pages
page.evaluate("""() => {
const selectors = [
"button.btn-primary",
'button[type="submit"]',
'input[type="submit"]',
"#loginSubmitButton",
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}""")
# Submit: Cloudron Pankow UI uses <div role="button"> instead of <button>.
submitted = False
for text in ["Log in", "Sign in", "Submit", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
submitted = True
break
if not submitted:
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
submitted = True
page.wait_for_timeout(5000)
# Handle TOTP if prompted
@@ -351,15 +351,34 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
if totp_input and totp_input.is_visible():
totp_code = bw.get_totp(cloudron_item)
totp_input.fill(totp_code)
page.evaluate('''() => {
for (const sel of ["button.btn-primary", 'button[type="submit"]', 'input[type="submit"]']) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}''')
for text in ["Log in", "Sign in", "Submit", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
break
else:
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
page.wait_for_timeout(5000)
if "login" not in page.url.lower() and "openid" not in page.url.lower():
# After submit, check if we're past the login form.
# The OIDC flow stays on /openid/interaction/... but changes from
# login form to consent page. Check for absence of password field.
page.wait_for_timeout(3000)
has_password = page.query_selector('#inputPassword')
if not has_password or not has_password.is_visible():
log.info(f"[{name}] Cloudron panel login successful")
return True
# Handle consent/authorize page if present
for consent_text in ["Continue", "Authorize", "Allow", "Accept"]:
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}")')
if consent_btn.count() > 0 and consent_btn.first.is_visible():
consent_btn.first.click()
page.wait_for_timeout(5000)
break
has_password = page.query_selector('#inputPassword')
if not has_password or not has_password.is_visible():
log.info(f"[{name}] Cloudron panel login successful")
return True
@@ -428,7 +447,7 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
# Handle Cloudron OIDC login page if redirected
if "openid" in page.url or "interaction" in page.url:
if not page.query_selector('input[type="password"]'):
if not page.query_selector("#inputPassword"):
log.info(f"[{name}] OIDC auto-consent (already authenticated)")
else:
log.info(f"[{name}] Handling Cloudron OIDC login")
@@ -437,26 +456,41 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
password = bw.get_item_password(cloudron_item)
page.wait_for_selector("#inputPassword", timeout=15000)
page.fill("#inputUsername", cloudron_email)
page.fill("#inputPassword", password)
page.click("#inputUsername")
page.keyboard.type(cloudron_email)
page.click("#inputPassword")
page.keyboard.type(password)
# Submit: Cloudron OIDC uses different button patterns than the panel
page.evaluate('''() => {
const selectors = [
"button.btn-primary",
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Log in")',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}''')
# Submit using Pankow UI pattern: div[role="button"]
for text in ["Log in", "Sign in", "Submit", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
break
else:
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
page.wait_for_timeout(5000)
# After OIDC login, may need consent — or may redirect back to app
# Handle TOTP if prompted
totp_input = page.query_selector("#inputTotp")
if totp_input and totp_input.is_visible():
totp_code = bw.get_totp(cloudron_item)
totp_input.fill(totp_code)
for text in ["Log in", "Sign in", "Submit", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
break
page.wait_for_timeout(5000)
# After OIDC login, may need consent
page.wait_for_timeout(3000)
for consent_text in ["Continue", "Authorize", "Allow", "Accept"]:
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}")')
if consent_btn.count() > 0 and consent_btn.first.is_visible():
consent_btn.first.click()
page.wait_for_timeout(5000)
break
page.wait_for_timeout(3000)
log.info(f"[{name}] SSO result: {page.url}")
@@ -498,32 +532,63 @@ def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
sso_button_selector='a[href*="oauth2/cloudron"]')
# Navigate to API token settings
page.goto(f"{url}/user/settings/applications", wait_until="networkidle")
page.wait_for_timeout(2000)
page.goto(f"{url}/user/settings/applications", wait_until="domcontentloaded")
page.wait_for_timeout(3000)
_debug_dump(page, f"gitea-settings-{name}")
# Generate new token
name_input = page.query_selector('input[name="name"]')
if name_input:
name_input.fill(token_name)
# Generate new token — the input may not be visible to Playwright
# (behind flash message or in collapsed section). Use JS to fill.
page.evaluate(f"""() => {{
const input = document.querySelector('#name');
if (input) {{
input.value = '{token_name}';
input.dispatchEvent(new Event('input', {{bubbles: true}}));
input.dispatchEvent(new Event('change', {{bubbles: true}}));
}}
}}""")
# Select scopes if checkboxes exist
for scope in gitea_cfg.get("scopes", ["api", "repo", "read:org"]):
scope_cb = page.query_selector(f'input[value="{scope}"]')
if scope_cb and not scope_cb.is_checked():
scope_cb.check()
# Select scopes via JS (radio buttons may also be non-visible)
scope_map = {
"repository": "write:repository",
"user": "write:user",
"organization": "write:organization",
"issue": "write:issue",
"package": "write:package",
"notification": "read:notification",
"misc": "read:misc",
}
page.evaluate("""(scopes) => {
for (const [cat, val] of Object.entries(scopes)) {
const radio = document.querySelector('input[value="' + val + '"]');
if (radio) { radio.checked = true; radio.dispatchEvent(new Event('change', {bubbles: true})); }
}
}""", scope_map)
gen_btn = page.query_selector('button:has-text("Generate Token")')
if gen_btn:
gen_btn.click()
page.wait_for_timeout(2000)
# Click Generate Token via JS
page.evaluate("""() => {
const btns = document.querySelectorAll('button');
for (const b of btns) {
if (b.textContent.trim() === 'Generate Token') { b.click(); return; }
}
}""")
page.wait_for_timeout(3000)
# Extract the generated token
token_el = page.query_selector('.ui.info.message code, .ui.message code, input[readonly]')
# Extract the generated token.
# Gitea shows it in a flash-info message div as plain text (the token
# string itself, not wrapped in <code>).
token_el = page.query_selector('.ui.info.message.flash-info, .ui.info.message')
if not token_el:
token_el = page.query_selector('.token-value, .access-token')
token_el = page.query_selector('.ui.message code, input[readonly]')
token = token_el.text_content().strip() if token_el else ""
token = ""
if token_el:
token = token_el.text_content().strip()
# The flash message may contain extra text; extract just the token
# (Gitea tokens are 40-char hex strings)
import re
match = re.search(r'[a-f0-9]{40}', token)
if match:
token = match.group(0)
if not token:
log.error(f"[{name}] Could not extract Gitea API token")
_debug_dump(page, f"gitea-no-token-{name}")