From 2258e1bd04842555deb10c58fbae6879840caedb Mon Sep 17 00:00:00 2001 From: TSYS Group COO Date: Thu, 13 Aug 2026 23:41:00 -0500 Subject: [PATCH] feat: Redmine SSO login + API key extraction working Redmine SSO flow: Cloudron login -> click "Continue with KNEL Cloud" button (#login-oauth-submit-1) -> authenticated. Required Cloudron admin to grant vp-techops access to the Redmine app first. API key was already present but hidden. Click "Show" in the .api-key-actions section to reveal it from the #api-access-key pre element. Key verified via X-Redmine-API-Key header. --- docker-compose.yml | 1 + provision-redmine.py | 288 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 provision-redmine.py diff --git a/docker-compose.yml b/docker-compose.yml index 94cac6a..c8e0844 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,3 +20,4 @@ services: - ./dump-sso-flows.py:/app/dump-sso-flows.py:ro - ./provision-discourse.py:/app/provision-discourse.py:ro - ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro + - ./provision-redmine.py:/app/provision-redmine.py:ro diff --git a/provision-redmine.py b/provision-redmine.py new file mode 100644 index 0000000..36a3aef --- /dev/null +++ b/provision-redmine.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +provision-redmine.py -- Redmine SSO login + API key extraction. + +Flow: +1. Cloudron login (establish OIDC session) +2. Redmine SSO via "Continue with KNEL Cloud" button +3. Navigate to /my/account +4. Find and generate API key + +Usage: + docker compose run --rm --entrypoint python3 provision provision-redmine.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") +REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com") +STATE_DIR = Path("/app/state") +BW_ITEM = "vp-techops Cloudron" +REDMINE_BW_ITEM = "vp-techops Redmine" +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"redmine-{label}-{ts}.png"), full_page=True) + except Exception: + pass + elements = page.evaluate("""() => { + const results = []; + document.querySelectorAll('input, button, a, [role="button"], label, code, pre, .ui.message, #api-access-key, [class*="api"]').forEach(el => { + const tag = el.tagName.toLowerCase(); + const text = (el.textContent || '').trim().substring(0, 80); + const id = el.id || ''; + const type = el.getAttribute('type') || ''; + const value = el.getAttribute('value') || ''; + const href = (el.getAttribute('href') || '').substring(0, 50); + const cls = (el.getAttribute('class') || '').substring(0, 60); + const vis = el.offsetParent !== null; + if (text || id || type || value || href) { + results.push('<'+tag+'> id='+id+' type='+type+' value='+value.substring(0,30)+' class='+cls+' vis='+vis+' text="'+text+'"'); + } + }); + return results; + }""") + body = page.evaluate("() => document.body.innerText.substring(0, 500)") + (STATE_DIR / f"redmine-{label}-{ts}.txt").write_text(f"URL: {page.url}\n\nBody: {body}\n\nElements:\n" + "\n".join(elements)) + print(f" [{label}] {len(elements)} elements") + + +def cloudron_login(page, bw): + password = bw.get_item_password(BW_ITEM) + page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000) + page.wait_for_timeout(2000) + if "login" not in page.url.lower() and "openid" not in page.url.lower(): + return + 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) + + +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() + + 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 ===") + cloudron_login(page, bw) + print(f" Cloudron: {page.url}") + + # Step 2: Redmine SSO + print("=== STEP 2: Redmine SSO ===") + page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + dump(page, "01-login-page") + + # Click SSO button + sso_btn = page.locator('#login-oauth-submit-1') + if sso_btn.count() == 0: + sso_btn = page.locator('button:has-text("KNEL"), button:has-text("Cloud"), button:has-text("Continue")') + if sso_btn.count() > 0 and sso_btn.first.is_visible(): + print(f" Clicking SSO button...") + sso_btn.first.click() + page.wait_for_timeout(5000) + dump(page, "02-after-sso-click") + + # Handle OIDC consent if needed + if "openid" in page.url.lower(): + print(f" OIDC page: {page.url}") + for consent in ["Continue", "Authorize", "Allow", "Accept"]: + cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")') + if cbtn.count() > 0 and cbtn.first.is_visible(): + cbtn.first.click() + page.wait_for_timeout(5000) + print(f" Clicked consent: {consent}") + break + dump(page, "03-after-consent") + else: + print(" SSO button not found!") + + current_url = page.url + body = page.evaluate("() => document.body.innerText.substring(0, 300)") + print(f" Current URL: {current_url}") + print(f" Body: {body[:200]}") + + if "/login" in current_url: + print(" STILL ON LOGIN PAGE -- SSO may have failed") + browser.close() + return + + print(" Redmine SSO: SUCCESS!") + + # Step 3: Navigate to account page for API key + print("=== STEP 3: Navigate to /my/account ===") + page.goto(f"{REDMINE_URL}/my/account", wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + dump(page, "04-account-page") + + body = page.evaluate("() => document.body.innerText") + print(f" Account body: {body[:300]}") + + # Step 4: Look for existing API key or generate new one + print("=== STEP 4: Find/generate API key ===") + + # Check if API key already exists on the page + api_key = "" + api_key_el = page.query_selector('#api-access-key, .api-access-key') + if api_key_el: + text = api_key_el.text_content().strip() + # Redmine API keys are 40-char hex or alphanumeric + matches = re.findall(r'[a-f0-9]{40}|[A-Za-z0-9]{40}', text) + if matches: + api_key = matches[0] + print(f" Found existing API key: {api_key[:12]}...") + + if not api_key: + # Look in page text for the key + matches = re.findall(r'\b([a-f0-9]{40})\b', body) + if matches: + api_key = matches[0] + print(f" Found API key in page text: {api_key[:12]}...") + + if not api_key: + # Try clicking "Show" first to reveal an existing key + show_btn = page.locator('.api-key-actions a:has-text("Show"), a:has-text("Show")') + if show_btn.count() > 0 and show_btn.first.is_visible(): + print(" Clicking Show to reveal existing key...") + show_btn.first.click() + page.wait_for_timeout(2000) + # Check the now-visible #api-access-key pre element + api_el = page.query_selector('#api-access-key') + if api_el: + text = api_el.text_content().strip() + matches = re.findall(r'[a-f0-9]{40}', text) + if matches: + api_key = matches[0] + print(f" Revealed API key: {api_key[:12]}...") + + if not api_key: + # Generate via the Reset link in the API access key section. + # Use JS to find the Reset link that is a sibling of #api-access-key's container. + print(" No key visible, clicking API key Reset...") + reset_clicked = page.evaluate("""() => { + // Find the API access key section and its Reset link + const apiSection = document.querySelector('#api-access-key'); + if (!apiSection) return false; + // Walk up to the parent container, then find Reset link within it + let container = apiSection.closest('div, p, fieldset'); + while (container && container.parentElement) { + const reset = Array.from(container.querySelectorAll('a, button')).find(el => + el.textContent.trim().toLowerCase() === 'reset' && el.offsetParent !== null + ); + if (reset) { + reset.click(); + return true; + } + container = container.parentElement; + if (container.tagName === 'FIELDSET' || container.tagName === 'FORM') break; + } + return false; + }""") + if reset_clicked: + page.wait_for_timeout(3000) + dump(page, "05-after-reset") + + # Handle confirmation dialog if Redmine asks + confirm = page.locator('button:has-text("OK"), button:has-text("Confirm"), button:has-text("Yes"), input[value="OK"], input[value="Yes"]') + if confirm.count() > 0 and confirm.first.is_visible(): + print(" Clicking confirmation...") + confirm.first.click() + page.wait_for_timeout(3000) + dump(page, "06-after-confirm") + + # Now check for the key + body = page.evaluate("() => document.body.innerText") + # Try #api-access-key element first + api_el = page.query_selector('#api-access-key') + if api_el: + text = api_el.text_content().strip() + matches = re.findall(r'[a-f0-9]{40}', text) + if matches: + api_key = matches[0] + print(f" Generated API key: {api_key[:12]}...") + # Fallback: scan full body + if not api_key: + matches = re.findall(r'\b([a-f0-9]{40})\b', body) + if matches: + api_key = matches[0] + print(f" Found API key in body: {api_key[:12]}...") + else: + print(" Could not find API key Reset link via DOM traversal") + + # Also try data attributes + if not api_key: + api_el = page.query_selector('[data-key], [data-api-key]') + if api_el: + api_key = api_el.get_attribute("data-key") or api_el.get_attribute("data-api-key") or "" + if api_key: + print(f" Found in data attr: {api_key[:12]}...") + + if api_key: + print(f"\n API KEY: {api_key}") + print("=== STEP 5: Store in Bitwarden ===") + existing = bw.get_item_id(REDMINE_BW_ITEM) + if existing: + bw.update_item(REDMINE_BW_ITEM, password=api_key) + print(f" Updated BW item '{REDMINE_BW_ITEM}'") + else: + bw.create_item( + name=REDMINE_BW_ITEM, + username=USERNAME, + password=api_key, + uris=[REDMINE_URL], + collection_name="default", + ) + print(f" Created BW item '{REDMINE_BW_ITEM}'") + else: + print(" Could not extract API key") + # Dump all elements with 'api' in their attributes + api_elements = page.evaluate("""() => { + return Array.from(document.querySelectorAll('*')).filter(el => { + const id = (el.id || '').toLowerCase(); + const cls = (el.getAttribute('class') || '').toLowerCase(); + return id.includes('api') || cls.includes('api'); + }).map(el => ({ + tag: el.tagName, + id: el.id, + cls: el.getAttribute('class') || '', + text: (el.textContent || '').trim().substring(0, 80), + })); + }""") + print(f" API-related elements: {api_elements}") + + browser.close() + print("\n=== DONE ===") + + +if __name__ == "__main__": + main()