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
|
# If redirected to OIDC login page, fill that too
|
||||||
# (Cloudron login.html and OIDC login look similar, both use #inputUsername/#inputPassword)
|
# (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.wait_for_selector("#inputPassword", timeout=15000)
|
||||||
page.fill("#inputUsername", cloudron_email)
|
page.click("#inputUsername")
|
||||||
page.fill("#inputPassword", password)
|
page.keyboard.type(cloudron_email)
|
||||||
|
page.click("#inputPassword")
|
||||||
|
page.keyboard.type(password)
|
||||||
|
|
||||||
# Submit: Cloudron uses different button types on panel vs OIDC pages
|
# Submit: Cloudron Pankow UI uses <div role="button"> instead of <button>.
|
||||||
page.evaluate("""() => {
|
submitted = False
|
||||||
const selectors = [
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
||||||
"button.btn-primary",
|
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
||||||
'button[type="submit"]',
|
if btn.count() > 0 and btn.first.is_visible():
|
||||||
'input[type="submit"]',
|
btn.first.click()
|
||||||
"#loginSubmitButton",
|
submitted = True
|
||||||
];
|
break
|
||||||
for (const sel of selectors) {
|
if not submitted:
|
||||||
const el = document.querySelector(sel);
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
||||||
if (el) { el.click(); return; }
|
submitted = True
|
||||||
}
|
|
||||||
}""")
|
|
||||||
page.wait_for_timeout(5000)
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
# Handle TOTP if prompted
|
# 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():
|
if totp_input and totp_input.is_visible():
|
||||||
totp_code = bw.get_totp(cloudron_item)
|
totp_code = bw.get_totp(cloudron_item)
|
||||||
totp_input.fill(totp_code)
|
totp_input.fill(totp_code)
|
||||||
page.evaluate('''() => {
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
||||||
for (const sel of ["button.btn-primary", 'button[type="submit"]', 'input[type="submit"]']) {
|
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
||||||
const el = document.querySelector(sel);
|
if btn.count() > 0 and btn.first.is_visible():
|
||||||
if (el) { el.click(); return; }
|
btn.first.click()
|
||||||
}
|
break
|
||||||
}''')
|
else:
|
||||||
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
||||||
page.wait_for_timeout(5000)
|
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")
|
log.info(f"[{name}] Cloudron panel login successful")
|
||||||
return True
|
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
|
# Handle Cloudron OIDC login page if redirected
|
||||||
if "openid" in page.url or "interaction" in page.url:
|
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)")
|
log.info(f"[{name}] OIDC auto-consent (already authenticated)")
|
||||||
else:
|
else:
|
||||||
log.info(f"[{name}] Handling Cloudron OIDC login")
|
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)
|
password = bw.get_item_password(cloudron_item)
|
||||||
|
|
||||||
page.wait_for_selector("#inputPassword", timeout=15000)
|
page.wait_for_selector("#inputPassword", timeout=15000)
|
||||||
page.fill("#inputUsername", cloudron_email)
|
page.click("#inputUsername")
|
||||||
page.fill("#inputPassword", password)
|
page.keyboard.type(cloudron_email)
|
||||||
|
page.click("#inputPassword")
|
||||||
|
page.keyboard.type(password)
|
||||||
|
|
||||||
# Submit: Cloudron OIDC uses different button patterns than the panel
|
# Submit using Pankow UI pattern: div[role="button"]
|
||||||
page.evaluate('''() => {
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
||||||
const selectors = [
|
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
||||||
"button.btn-primary",
|
if btn.count() > 0 and btn.first.is_visible():
|
||||||
'button[type="submit"]',
|
btn.first.click()
|
||||||
'input[type="submit"]',
|
break
|
||||||
'button:has-text("Log in")',
|
else:
|
||||||
];
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
||||||
for (const sel of selectors) {
|
|
||||||
const el = document.querySelector(sel);
|
|
||||||
if (el) { el.click(); return; }
|
|
||||||
}
|
|
||||||
}''')
|
|
||||||
page.wait_for_timeout(5000)
|
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)
|
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)
|
page.wait_for_timeout(3000)
|
||||||
log.info(f"[{name}] SSO result: {page.url}")
|
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"]')
|
sso_button_selector='a[href*="oauth2/cloudron"]')
|
||||||
|
|
||||||
# Navigate to API token settings
|
# Navigate to API token settings
|
||||||
page.goto(f"{url}/user/settings/applications", wait_until="networkidle")
|
page.goto(f"{url}/user/settings/applications", wait_until="domcontentloaded")
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(3000)
|
||||||
_debug_dump(page, f"gitea-settings-{name}")
|
_debug_dump(page, f"gitea-settings-{name}")
|
||||||
|
|
||||||
# Generate new token
|
# Generate new token — the input may not be visible to Playwright
|
||||||
name_input = page.query_selector('input[name="name"]')
|
# (behind flash message or in collapsed section). Use JS to fill.
|
||||||
if name_input:
|
page.evaluate(f"""() => {{
|
||||||
name_input.fill(token_name)
|
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
|
# Select scopes via JS (radio buttons may also be non-visible)
|
||||||
for scope in gitea_cfg.get("scopes", ["api", "repo", "read:org"]):
|
scope_map = {
|
||||||
scope_cb = page.query_selector(f'input[value="{scope}"]')
|
"repository": "write:repository",
|
||||||
if scope_cb and not scope_cb.is_checked():
|
"user": "write:user",
|
||||||
scope_cb.check()
|
"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")')
|
# Click Generate Token via JS
|
||||||
if gen_btn:
|
page.evaluate("""() => {
|
||||||
gen_btn.click()
|
const btns = document.querySelectorAll('button');
|
||||||
page.wait_for_timeout(2000)
|
for (const b of btns) {
|
||||||
|
if (b.textContent.trim() === 'Generate Token') { b.click(); return; }
|
||||||
|
}
|
||||||
|
}""")
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
# Extract the generated token
|
# Extract the generated token.
|
||||||
token_el = page.query_selector('.ui.info.message code, .ui.message code, input[readonly]')
|
# 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:
|
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:
|
if not token:
|
||||||
log.error(f"[{name}] Could not extract Gitea API token")
|
log.error(f"[{name}] Could not extract Gitea API token")
|
||||||
_debug_dump(page, f"gitea-no-token-{name}")
|
_debug_dump(page, f"gitea-no-token-{name}")
|
||||||
|
|||||||
Reference in New Issue
Block a user