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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user