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:
+124
-59
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user